├── .editorconfig ├── .gitignore ├── .prettierrc ├── .vscode └── extensions.json ├── README.md ├── bundle.js ├── docs └── images │ └── example.png ├── package.json ├── public └── favicon.ico ├── src ├── App.vue ├── api │ ├── _init.ts │ ├── index.ts │ └── raw.ts ├── components │ └── HelloWorld.vue ├── constants.ts ├── main.ts ├── models │ └── index.ts ├── pages │ ├── Controller │ │ └── index.vue │ └── D2Graph │ │ ├── components │ │ └── Hello.vue │ │ └── index.vue ├── state │ └── index.ts ├── typings │ ├── global.d.ts │ └── shims-vue.d.ts └── utils │ ├── NotionAPI │ ├── fetch.ts │ ├── helpers.ts │ ├── index.ts │ └── types.ts │ ├── helper.ts │ └── index.ts ├── tsconfig.json └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | 7 | yarn.lock -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [] 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Archived 2 | 3 | The current implementation isn't in the right direction. ~I'm considering refacotring it using official API~, I'm considering rebuilding, but it takes time. 4 | 5 | There's others project related to notion I'm developing now 6 | 7 | - [notion-renderer](https://github.com/iheyunfei/notion-renderer) 8 | - [quipu](https://github.com/iheyunfei/quipu) 9 | 10 | 11 | # Introduction 12 | 13 | no-graph lets you see the connections between pages by Relationship Chart 14 | 15 | no-graph 让你可以通过关系图的方式来查看页面之间的联系 16 | 17 | ![example](./docs/images/example.png) 18 | 19 | # Security 20 | 21 | no-graph 没有使用第三方的 API 代理,而是通过在 Notion 页面内注入 JS 后,直接请求官方的 API 地址,你的数据只会经过官方服务器和本地存储,不会经手第三方。 22 | 23 | Instead of using third-party API proxies, no-graph requests the official API address directly by injecting JS inside the Notion page, so your data only goes through the official servers and local storage, not through third parties. 24 | 25 | # TODO 26 | 27 | - [x] pages 28 | - [ ] database 29 | - [ ] backlinks 30 | 31 | # Usage 32 | 33 | 1. Install tampermonkey 34 | - [edge](https://microsoftedge.microsoft.com/addons/detail/tampermonkey/iikmkjmpaadaobahmlepeloendndfphd) 35 | - [chrome](https://chrome.google.com/webstore/detail/tampermonkey/dhdgffkkebhmkfjojejmpbldmpobfkfo?hl=zh-CN) 36 | 37 | ~2. [Install script using greasyfork(deprecated)](https://twitter.com/iheyunfei/status/1457892651114455045)~ 38 | 39 | 2. Install script locally 40 | - click `add new script` in tampermonkey extension 41 | - copy-paste [these codes](https://github.com/iheyunfei/no-graph/blob/main/bundle.js) 42 | 3. Open notion 43 | 4. Click the checkbox in the upper left corner to turn on no-graph 44 | -------------------------------------------------------------------------------- /docs/images/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyf0/no-graph/795c8154c199ffea612c46df4c464501f038f067/docs/images/example.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "no-graph", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build:dev": "cross-env NODE_ENV=development webpack", 7 | "build:watch": "cross-env NODE_ENV=development webpack --watch", 8 | "build": "cross-env NODE_ENV=production webpack" 9 | }, 10 | "dependencies": { 11 | "@vueuse/core": "^4.9.1", 12 | "axios": "^0.21.1", 13 | "echarts": "^5.1.1", 14 | "lodash-es": "^4.17.21", 15 | "notion-client": "^4.4.0", 16 | "p-limit": "^3.1.0", 17 | "slugify": "^1.5.0", 18 | "vue": "^3.0.5", 19 | "vue-echarts": "^6.0.0-rc.4" 20 | }, 21 | "devDependencies": { 22 | "@types/lodash-es": "^4.17.4", 23 | "@vitejs/plugin-vue": "^1.2.2", 24 | "@vue/compiler-sfc": "^3.0.5", 25 | "cross-env": "^7.0.3", 26 | "css-loader": "^5.2.4", 27 | "style-loader": "^2.0.0", 28 | "ts-loader": "^9.1.1", 29 | "typescript": "^4.1.3", 30 | "vite": "^2.2.3", 31 | "vue-loader": "^16.2.0", 32 | "vue-tsc": "^0.0.24", 33 | "webpack": "^5.36.0", 34 | "webpack-cli": "^4.6.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyf0/no-graph/795c8154c199ffea612c46df4c464501f038f067/public/favicon.ico -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 54 | 55 | 61 | -------------------------------------------------------------------------------- /src/api/_init.ts: -------------------------------------------------------------------------------- 1 | import Axios from 'axios' 2 | 3 | Axios.defaults.withCredentials = true 4 | Axios.defaults.baseURL = 'https://www.notion.so/api/v3' 5 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | import './_init' 2 | 3 | import { GNode } from '@/models' 4 | import pLimit from 'p-limit' 5 | import { loadCachedPageChunk, loadUserContent } from './raw' 6 | 7 | export * from './raw' 8 | 9 | // export const getSpaces = async () => Axios.post('/getSpaces', {}) 10 | 11 | const runWithLimit = pLimit(2) 12 | let readedPidSet = new Set() 13 | 14 | // const parseCollection = async (value: any, path: string[]) => { 15 | // // if (readedPidSet.has(pid)) return 16 | // // readedPidSet.add(pid) 17 | // try { 18 | // const resp = await runWithLimit(() => queryCollection(value)) 19 | // console.log('resp', resp) 20 | // const { recordMap: pageChunk } = resp 21 | // const blocks = Object.values(pageChunk) 22 | // .map((item: any) => Object.values(item)) 23 | // .flat(1) 24 | // .map((item: any) => item.value) 25 | // // .filter((value) => value != null && !ignoreGNodeTypeSet.has(getBlockType(value) as any)) 26 | // const nodeMap = new Map( 27 | // blocks.map((b) => GNode.from(b)).map((n) => [n.opts.id, n]) 28 | // ) 29 | // console.log('nodeMap', nodeMap) 30 | // ;[...nodeMap.values()].forEach((child) => { 31 | // if (path.includes(child.opts.id)) { 32 | // return 33 | // } 34 | // const pid = path[path.length - 1] 35 | // GEdge.of(pid, child.opts.id) 36 | 37 | // switch (child.opts.type) { 38 | // case 'Page': 39 | // { 40 | // thunks.push(() => parsePage(child.opts.id, [...path, pid])) 41 | // } 42 | // break 43 | // } 44 | // }) 45 | // } catch (err) { 46 | // console.log('parsePage error') 47 | // console.error(err) 48 | // } 49 | // } 50 | 51 | const parsePage = async (pid: string) => { 52 | if (readedPidSet.has(pid)) return 53 | readedPidSet.add(pid) 54 | try { 55 | const resp = await runWithLimit(() => loadCachedPageChunk(pid)) 56 | const { recordMap: pageChunk } = resp 57 | const blocks = Object.values(pageChunk) 58 | .map((item: any) => Object.values(item)) 59 | .flat(1) 60 | .map((item: any) => item.value) 61 | .filter((value) => value != null) 62 | 63 | const nodes = blocks.map((b) => GNode.from(b)) 64 | const thunks: (() => Promise)[] = [] 65 | nodes.forEach((child) => { 66 | switch (child.opts.type) { 67 | case 'CollectionView': 68 | { 69 | // TODO: parse CollectionView 70 | // thunks.push(() => parseCollection(child.source, [...path, pid])) 71 | } 72 | break 73 | case 'Page': 74 | { 75 | thunks.push(() => parsePage(child.opts.id)) 76 | } 77 | break 78 | } 79 | }) 80 | await Promise.all(thunks.map(fn => fn())) 81 | } catch (err) { 82 | console.log('parsePage error') 83 | console.error(err) 84 | } 85 | } 86 | 87 | export const makeWorld = async () => { 88 | try { 89 | const { recordMap: userContent } = await loadUserContent() 90 | if (userContent.space) { 91 | await Promise.all( 92 | Object.values(userContent.space).map(async (item: any) => { 93 | // const spaceNode = GNode.from(item.value) 94 | const pids = [...item.value.pages] 95 | await Promise.all(pids.map((pid) => parsePage(pid))) 96 | }) 97 | ) 98 | } 99 | } catch (err) { 100 | console.log('makeWorld error') 101 | console.error(err) 102 | } 103 | } 104 | if (__DEV__) { 105 | setInterval(() => { 106 | console.log( 107 | 'runWithLimit.activeCount, runWithLimit.pendingCount', 108 | runWithLimit.activeCount, 109 | runWithLimit.pendingCount 110 | ) 111 | }, 1000) 112 | } 113 | -------------------------------------------------------------------------------- /src/api/raw.ts: -------------------------------------------------------------------------------- 1 | import Axios from 'axios' 2 | 3 | const storeKey = 'noGraph$cacheStore' 4 | export const cacheStore = { 5 | cacheObj: JSON.parse( 6 | localStorage.getItem(storeKey) ?? JSON.stringify({}) 7 | ) as { [key: string]: any }, 8 | get(key: string): T | null { 9 | return this.cacheObj[key] ?? null 10 | }, 11 | set(key: string, value: any) { 12 | this.cacheObj[key] = value 13 | localStorage.setItem(storeKey, JSON.stringify(this.cacheObj)) 14 | }, 15 | clear() { 16 | this.cacheObj = {} 17 | localStorage.setItem(storeKey, JSON.stringify({})) 18 | } 19 | } 20 | if (__DEV__) { 21 | // @ts-ignore 22 | window.c = cacheStore 23 | } 24 | 25 | export const loadUserContent = async () => { 26 | const cacheResult = cacheStore.get(`loadUserContent`) 27 | if (cacheResult !== null) { 28 | return cacheResult 29 | } 30 | const { data } = await Axios.post<{ 31 | recordMap: { 32 | [key: string]: any 33 | } 34 | }>('/loadUserContent', { 35 | cursor: { stack: [] }, 36 | chunkNumber: 0, 37 | limit: 50, 38 | verticalColumns: false, 39 | }) 40 | cacheStore.set('loadUserContent', data) 41 | return data 42 | } 43 | 44 | export const loadCachedPageChunk = async (pid: string) => { 45 | const cacheResult = cacheStore.get(`loadCachedPageChunk/${pid}`) 46 | if (cacheResult !== null) { 47 | return cacheResult 48 | } 49 | const { data } = await Axios.post<{ 50 | stack: any[] 51 | recordMap: { 52 | [key: string]: any 53 | } 54 | }>('/loadCachedPageChunk', { 55 | pageId: pid, 56 | limit: 30, 57 | cursor: { stack: [] }, 58 | chunkNumber: 0, 59 | verticalColumns: false, 60 | }) 61 | cacheStore.set(`loadCachedPageChunk/${pid}`, data) 62 | 63 | return data 64 | } 65 | 66 | export const getBacklinksForBlock = async (bid: string) => { 67 | const cacheResult = cacheStore.get(`getBacklinksForBlock/${bid}`) 68 | if (cacheResult !== null) { 69 | return cacheResult 70 | } 71 | const { data } = await Axios.post('/getBacklinksForBlock', { 72 | blockId: bid, 73 | }) 74 | cacheStore.set(`getBacklinksForBlock/${bid}`, data) 75 | return data 76 | } 77 | 78 | export const queryCollection = async (value: { 79 | id: string 80 | collection_id: string 81 | }) => { 82 | const cacheResult = cacheStore.get( 83 | `queryCollection/${value.id}${value.collection_id}` 84 | ) 85 | if (cacheResult !== null) { 86 | return cacheResult 87 | } 88 | const { data } = await Axios.post('/queryCollection', { 89 | collectionId: value.collection_id, 90 | collectionViewId: value.id, 91 | query: { sort: [{ property: 'xYf?', direction: 'descending' }] }, 92 | loader: { 93 | type: 'table', 94 | limit: 50, 95 | searchQuery: '', 96 | userTimeZone: 'Asia/Hong_Kong', 97 | loadContentCover: true, 98 | }, 99 | }) 100 | cacheStore.set(`queryCollection/${value.id}${value.collection_id}`, data) 101 | return data 102 | } 103 | -------------------------------------------------------------------------------- /src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 53 | 54 | 71 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const THEMES = ['auto', 'light', 'dark'] as const 2 | 3 | export const supportNotionTypes = [ 4 | // 'Unkown', 5 | 'Space', 6 | 'Page', 7 | 'CollectionViewPage', 8 | // TODO: support 9 | // 'CollectionView', 10 | // 'ToDo', 11 | // 'Bookmark', 12 | // 'Header', 13 | // 'SubHeader', 14 | // 'SubSubHeader', 15 | // 'Bookmark', 16 | // 'File', 17 | ] 18 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | 4 | function mountContainer() { 5 | window.addEventListener('load', () => { 6 | setTimeout(function polling() { 7 | const sidebar = document.querySelector('.notion-sidebar') as HTMLDivElement | null 8 | if (sidebar) { 9 | const controllerDiv = document.createElement('div') 10 | createApp(App).mount(controllerDiv) 11 | sidebar.insertBefore(controllerDiv, sidebar.firstElementChild) 12 | } else { 13 | setTimeout(polling, 150) 14 | } 15 | }, 150) 16 | }) 17 | } 18 | 19 | mountContainer() 20 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | import { getBlockType } from '@/utils' 2 | 3 | // const ignoreGNodeTypes = [ 4 | // 'TableOfContents', 5 | // 'Text', 6 | // 'Quote', 7 | // 'Embed', 8 | // 'Image', 9 | // 'Code', 10 | // 'Divider', 11 | // 'Callout', 12 | // 'Calendar', 13 | // // special 14 | // // 'Unkown', 15 | // // TODO: support 16 | // 'NumberedList', 17 | // 'BulletedList', 18 | // 'Table', 19 | // 'ColumnList', 20 | // 'Column', 21 | // 'Board', 22 | // 'List', 23 | // ] as const 24 | // export type IgnoreGNodeType = typeof ignoreGNodeTypes[number] 25 | // export const ignoreGNodeTypeSet = new Set(ignoreGNodeTypes) 26 | 27 | 28 | export const gNodeTypes = [ 29 | 'Unkown', 30 | 'Space', 31 | 'Page', 32 | ] as const 33 | export type GNodeType = typeof gNodeTypes[number] 34 | export const GNodeTypeSet = new Set(gNodeTypes) 35 | 36 | export class GNode { 37 | static nodes: { [id: string]: GNode } = {} 38 | static types = gNodeTypes 39 | get type(): GNodeType { 40 | switch (this.opts.type) { 41 | case 'Space': 42 | return 'Space' 43 | case 'CollectionViewPage': 44 | case 'Page': 45 | return 'Page' 46 | default: 47 | return 'Unkown' 48 | } 49 | } 50 | get size() { 51 | switch (this.type) { 52 | case 'Space': 53 | return 80 54 | case 'Page': 55 | return 30 56 | case 'Unkown': 57 | default: 58 | return 10 59 | } 60 | } 61 | 62 | get name() { 63 | switch (this.opts.type) { 64 | case 'Space': 65 | return this.source.name 66 | case 'Bookmark': { 67 | return ( 68 | this.source.properties.title ?? this.source.properties.link 69 | ).join(' ') 70 | }; 71 | case 'File': { 72 | return this.source.properties?.title.join(' ') ?? 'Unkown File' 73 | } 74 | case 'Header': 75 | case 'SubHeader': 76 | case 'SubSubHeader': 77 | case 'ToDo': 78 | case 'Page': 79 | return this.source.properties?.title.join(' ') ?? 'Unkown Title' 80 | case 'CollectionViewPage': { 81 | const c = GNode.nodes[this.source.collection_id] 82 | return c?.source.name?.join(' ') ?? 'Unkown CollectionView' 83 | } 84 | case 'Unkown': return 'Unkown' 85 | default: { 86 | let check = this.opts.type 87 | return this.opts.type ?? 'Unkown Block' 88 | } 89 | } 90 | } 91 | 92 | constructor( 93 | public opts: { 94 | type: string 95 | id: string 96 | }, 97 | public source: any 98 | ) {} 99 | 100 | static from(value: any) { 101 | const type = getBlockType(value) 102 | const n = new GNode( 103 | { 104 | type, 105 | id: value.id, 106 | }, 107 | value 108 | ) 109 | GNode.nodes[n.opts.id] = n 110 | return n 111 | } 112 | } 113 | 114 | export class GEdge { 115 | constructor( 116 | public opts: { 117 | from: string 118 | to: string 119 | } 120 | ) {} 121 | 122 | static of(from: string, to: string) { 123 | const e = new GEdge({ from, to }) 124 | return e 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/pages/Controller/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 20 | 21 | 28 | -------------------------------------------------------------------------------- /src/pages/D2Graph/components/Hello.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 77 | 78 | -------------------------------------------------------------------------------- /src/pages/D2Graph/index.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 200 | 201 | 226 | -------------------------------------------------------------------------------- /src/state/index.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | 3 | export const isShowGraphViewS = ref(false) 4 | -------------------------------------------------------------------------------- /src/typings/global.d.ts: -------------------------------------------------------------------------------- 1 | declare const __DEV__: boolean 2 | -------------------------------------------------------------------------------- /src/typings/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import { DefineComponent } from 'vue' 3 | const component: DefineComponent<{}, {}, any> 4 | export default component 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/NotionAPI/fetch.ts: -------------------------------------------------------------------------------- 1 | import { NotionResponse } from './types'; 2 | 3 | const BASEURL = "https://www.notion.so/api/v3/"; 4 | 5 | const getAllBlocks = async ({ 6 | url, 7 | token, 8 | limit, 9 | stack, 10 | chunkNumber, 11 | res, 12 | resolve, 13 | reject, 14 | body, 15 | } : { 16 | url: string, 17 | token: string, 18 | limit: number, 19 | stack: Array, 20 | chunkNumber: number, 21 | res: { recordMap: {block: object } }, 22 | resolve: Function, 23 | reject: Function, 24 | body?: object, 25 | }) => { 26 | return fetch(url, { 27 | headers: { 28 | accept: "*/*", 29 | "accept-language": "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7", 30 | "content-type": "application/json", 31 | cookie: `token_v2=${token};` 32 | }, 33 | body: JSON.stringify({ 34 | cursor: { stack }, 35 | chunkNumber, 36 | ...body, 37 | limit, 38 | verticalColumns: false 39 | }), 40 | method: "POST" 41 | }) 42 | .then(response => response.json()) 43 | .then(r => { 44 | if (((r.cursor || {}).stack || {}).length) { 45 | getAllBlocks({ 46 | url,token,limit, stack: r.cursor.stack, 47 | chunkNumber: chunkNumber + 1, 48 | res: { 49 | recordMap: { 50 | block: { 51 | ...res.recordMap.block, 52 | ...r.recordMap.block, 53 | } 54 | } 55 | }, 56 | resolve, 57 | reject, 58 | body 59 | }) 60 | } else { 61 | if (r.errorId) { 62 | reject(r); 63 | } 64 | const ret: NotionResponse = { 65 | recordMap: { 66 | block: { 67 | ...res.recordMap.block, 68 | ...(r.recordMap || {}).block, 69 | } 70 | } 71 | }; 72 | resolve(ret) 73 | } 74 | }) 75 | .catch((error: Error) => console.error(error)); 76 | }; 77 | 78 | function request({ 79 | endpoint, 80 | creds: { token }, 81 | body 82 | }: { 83 | endpoint: string; 84 | creds: { token: string }; 85 | body?: { limit?: number, pageId: string }; 86 | }): Promise { 87 | return new Promise((resolve, reject) => { 88 | getAllBlocks({ 89 | url: `${BASEURL}${endpoint}`, 90 | token, 91 | limit: (body || { limit: 50 }).limit || 50, 92 | stack: [], 93 | chunkNumber: 0, 94 | res: { 95 | recordMap: { block: {} } 96 | }, 97 | resolve, 98 | reject, 99 | body 100 | }) 101 | }); 102 | } 103 | export default request; -------------------------------------------------------------------------------- /src/utils/NotionAPI/helpers.ts: -------------------------------------------------------------------------------- 1 | import { NotionObject, Options, Attributes, PageDTO, formatter, htmlResponse } from './types'; 2 | import slugify from 'slugify'; 3 | 4 | // Seperator for good-looking HTML ;) 5 | const SEPERATOR = ''; 6 | 7 | // HTML Tag types 8 | const types = { 9 | page: 'a', 10 | text: 'p', 11 | header: 'h1', 12 | sub_header: 'h3', 13 | sub_sub_header: 'h5', 14 | divider: 'hr', 15 | break: 'br', 16 | numbered_list: 'ol', 17 | bulleted_list: 'ul', 18 | image: 'img' 19 | }; 20 | 21 | /** 22 | * Method that parses a Notion-Object to HTML 23 | * @param {*} ObjectToParse The Notion-Object 24 | * @param {*} options Options for parsing 25 | */ 26 | function formatToHtml( 27 | ObjectToParse: NotionObject, 28 | options: Options, 29 | index: number 30 | ) { 31 | let { type, properties, format } = ObjectToParse; 32 | // Get color 33 | const color = format && format.block_color; 34 | // Replace color with custom color if passed 35 | const customColor = 36 | color && 37 | options.colors && 38 | ((options.colors as any)[color.split('_')[0]] || color); 39 | // Set content 40 | const content = 41 | properties && 42 | properties.title && 43 | properties.title[0][0].replace(/\[.*\]:.{1,}/, ''); 44 | const source = 45 | properties && 46 | properties.source; 47 | const tags = (content && content[0] ? content[0][0] : '').match( 48 | /\[.{1,}\]: .{1,}/ 49 | ); 50 | const attrib = tags && tags[0].replace(/(\[|\])/g, '').split(':'); 51 | if (attrib && attrib.length == 2) { 52 | return { 53 | [attrib[0]]: attrib[1].trim() 54 | }; 55 | } 56 | 57 | // Only set Style if passed 58 | const property = 59 | customColor && color.includes('background') 60 | ? `style="background-color:${customColor.split('_')[0]}"` 61 | : `style="color:${customColor}"`; 62 | 63 | // Use ternary operator to return empty string instead of undefined 64 | const style = color ? ` ${property}` : ''; 65 | 66 | // Set type to break if no content is existent 67 | if (!content && type !== 'divider' && !source) { 68 | type = 'break'; 69 | } 70 | // Create HTML Tags with content 71 | switch (types[type]) { 72 | case types.page: { 73 | if (index === 0) { 74 | return `

${content}

`; 75 | } 76 | return null; 77 | } 78 | case types.divider: { 79 | return `<${types.divider}${style}/>`; 80 | } 81 | case types.break: { 82 | return `<${types.break} />`; 83 | } 84 | case types.numbered_list: 85 | case types.bulleted_list: { 86 | return `${content}`; 87 | } 88 | case types.image: { 89 | return `<${types.image}${style} src="${source}" />`; 90 | } 91 | default: { 92 | if (types[type]) 93 | return `<${types[type]}${style}>${content}`; 94 | return null; 95 | } 96 | } 97 | } 98 | 99 | /** 100 | * Formats a List of objects to HTML 101 | * @param {*} ObjectList List of Notion-Objects 102 | * @param {*} options Options for parsing 103 | * @param {*} htmlFormatter html renderer 104 | */ 105 | function formatList(ObjectList: Array, options: Options, htmlFormatter?: formatter) { 106 | const items = []; 107 | const attributes: Attributes = {}; 108 | for (let index = 0; index < ObjectList.length; index += 1) { 109 | const element = ObjectList[index]; 110 | let html: htmlResponse; 111 | if (htmlFormatter) { 112 | html = htmlFormatter(element, options, index, ObjectList); 113 | } else { 114 | html = formatToHtml(element, options, index); 115 | } 116 | if (html && typeof html === 'object') { 117 | const keys = Object.keys(html as Attributes); 118 | keys.forEach(key => { 119 | attributes[key] = (html as Attributes)[key]; 120 | }); 121 | } else if ( 122 | element && 123 | element.type.includes('list') && 124 | !element.type.includes('column') 125 | ) { 126 | // If it the element is the first ul or ol element 127 | if ( 128 | ObjectList[index - 1] && 129 | !ObjectList[index - 1].type.includes('list') 130 | ) { 131 | html = `<${types[element.type]}>${SEPERATOR}${html}`; 132 | } 133 | if ( 134 | index + 1 >= ObjectList.length || 135 | (ObjectList[index + 1] && !ObjectList[index + 1].type.includes('list')) 136 | ) { 137 | html = `${html}${SEPERATOR}`; 138 | } 139 | } 140 | if (typeof html === 'string') { 141 | items.push(html); 142 | } 143 | } 144 | const { format, created_time, last_edited_time, properties } = ObjectList[0]; 145 | const created_datetime = new Date(created_time).toDateString() 146 | const last_edited_datetime = new Date(last_edited_time).toDateString() 147 | const title = (properties && properties.title && properties.title[0][0]) || ''; 148 | const cover = 149 | format && format.page_cover 150 | ? format.page_cover.includes('http') 151 | ? format.page_cover 152 | : `https://www.notion.so${format.page_cover}` 153 | : null; 154 | return { 155 | items, 156 | attributes: { 157 | ...attributes, 158 | title, 159 | created_datetime, 160 | last_edited_datetime, 161 | slug: slugify(title, { lower: true }), 162 | cover, 163 | teaser: items 164 | .map(i => 165 | i 166 | .replace(/\[.{1,}\]: .{1,}/g, '') 167 | .replace(/\*\<\/a\>/g, '') 168 | .replace(/<[^>]*>/g, '') 169 | ) 170 | .filter(i => i) 171 | .join(' ') 172 | .trim() 173 | .substring(0, 200), 174 | icon: format ? format.page_icon : null 175 | } 176 | }; 177 | } 178 | 179 | /** 180 | * Creates a HTML Page out of a List of Notion-Objects 181 | * @param {*} ObjectList List of Notion-Objects 182 | * @param {*} options Options for parsing 183 | * @param {*} htmlFormatter html renderer 184 | */ 185 | function toHTMLPage( 186 | ObjectList: Array, 187 | options: Options, 188 | htmlFormatter?: formatter 189 | ): PageDTO { 190 | const { items, attributes } = formatList(ObjectList, options, htmlFormatter); 191 | const elementsString = items.join(''); 192 | return { 193 | HTML: elementsString ? `
${elementsString}
` : '', 194 | Attributes: { ...attributes, id: ObjectList[0].id } 195 | }; 196 | } 197 | 198 | export function handleNotionError(err: Error) { 199 | if (err.message.includes('block')) { 200 | console.error('Authentication Error: Please check your token!'); 201 | } else { 202 | console.error(err); 203 | } 204 | } 205 | 206 | export function isNotionID(id: string) { 207 | const idRegex = new RegExp( 208 | /[a-z,0-9]{8}-[a-z,0-9]{4}-[a-z,0-9]{4}-[a-z,0-9]{4}-[a-z,0-9]{12}/g 209 | ); 210 | return idRegex.test(id); 211 | } 212 | 213 | export default toHTMLPage; -------------------------------------------------------------------------------- /src/utils/NotionAPI/index.ts: -------------------------------------------------------------------------------- 1 | import notionFetch from './fetch'; 2 | import makeHTML, { handleNotionError } from './helpers'; 3 | import { NotionResponse, Options, PageDTO, formatter } from './types'; 4 | 5 | /** 6 | * The Notion API Wrapper Class 7 | */ 8 | export class NotionAPI { 9 | creds: { token: string }; 10 | options: Options; 11 | /** 12 | * Creates a new Notion API Wrapper instance 13 | * if no token is provided it will look for the ENV Variable NOTION_TOKEN 14 | * @param {Object} Options 15 | */ 16 | constructor({ 17 | token, 18 | options = { 19 | colors: {}, 20 | pageUrl: '/page?id=' 21 | } 22 | }: { 23 | token: string; 24 | options: Options; 25 | }) { 26 | const notionToken = token; 27 | if (!notionToken) 28 | throw new Error('You need to provide the token to use the API'); 29 | this.creds = { 30 | token: notionToken 31 | }; 32 | this.options = options; 33 | } 34 | 35 | /** 36 | * Gets all PageIds from the user 37 | */ 38 | getPages() { 39 | return notionFetch({ endpoint: 'loadUserContent', creds: this.creds }) 40 | .then((r: NotionResponse) => { 41 | const pages = r.recordMap.block; 42 | return Object.keys(pages); 43 | }) 44 | .catch((e: Error) => { 45 | handleNotionError(e); 46 | return [] as Array; 47 | }); 48 | } 49 | 50 | /** 51 | * Gets the content of a page by ID as HTML 52 | * @param {string} pageId The ID of the notion page 53 | */ 54 | getPageById( 55 | pageId: string, 56 | htmlFormatter?: formatter, 57 | limit?: number, 58 | ) { 59 | return notionFetch({ 60 | endpoint: 'loadPageChunk', 61 | creds: this.creds, 62 | body: { pageId, limit } 63 | }) 64 | .then((r: NotionResponse) => { 65 | const entries = r.recordMap.block; 66 | const values = Object.values(entries).map(value => { 67 | const { id, type, properties, format, content, created_time, last_edited_time } = value.value; 68 | return { id, type, properties, format, content, created_time, last_edited_time }; 69 | }); 70 | return makeHTML(values, this.options, htmlFormatter); 71 | }) 72 | .catch( 73 | (e: Error): PageDTO => { 74 | handleNotionError(e); 75 | return {}; 76 | } 77 | ); 78 | } 79 | 80 | /** 81 | * Method to getAll Pages with metadata starting from the entrypoint. 82 | * @param startingPageId The ID of the page where your blog home is. Acts as a starting point 83 | */ 84 | async getPagesByIndexId(startingPageId: string) { 85 | return notionFetch({ 86 | endpoint: 'loadPageChunk', 87 | creds: this.creds, 88 | body: { pageId: startingPageId } 89 | }) 90 | .then(async (r: NotionResponse) => { 91 | const entries = Object.values(r.recordMap.block).filter( 92 | ({ value }) => value.type === 'page' 93 | ); 94 | return await Promise.all( 95 | entries.map(({ value }) => this.getPageById(value.id)) 96 | ); 97 | }) 98 | .catch((e: Error) => { 99 | handleNotionError(e); 100 | return [] as Array; 101 | }); 102 | } 103 | 104 | /** 105 | * Gets all HTML (WIP) 106 | */ 107 | async getAllHTML() { 108 | try { 109 | const pageIds = (await this.getPages()) as Array; 110 | const elems = await Promise.all(pageIds.map(id => this.getPageById(id))); 111 | return elems; 112 | } catch (error: any) { 113 | handleNotionError(error); 114 | return []; 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/utils/NotionAPI/types.ts: -------------------------------------------------------------------------------- 1 | export type NotionObject = { 2 | id: string; 3 | type: 4 | | 'page' 5 | | 'text' 6 | | 'header' 7 | | 'sub_header' 8 | | 'sub_sub_header' 9 | | 'divider' 10 | | 'break' 11 | | 'numbered_list' 12 | | 'bulleted_list'; 13 | properties: { 14 | source?: Array>; 15 | title?: Array>; 16 | }; 17 | format: { 18 | page_icon: string; 19 | page_cover: string; 20 | page_cover_position: number; 21 | block_color: string; 22 | }; 23 | content: Array; 24 | created_time: number; 25 | last_edited_time: number 26 | }; 27 | 28 | export type NotionResponse = { 29 | recordMap: { 30 | block: { 31 | id: { 32 | value: NotionObject; 33 | }; 34 | }; 35 | }; 36 | }; 37 | export type Options = { 38 | pageUrl?: string; 39 | colors?: { 40 | red?: string; 41 | brown?: string; 42 | orange?: string; 43 | yellow?: string; 44 | teal?: string; 45 | blue?: string; 46 | purple?: string; 47 | pink?: string; 48 | }; 49 | }; 50 | 51 | export interface Attributes { 52 | [key: string]: string | null; 53 | } 54 | 55 | export interface PageDTO { 56 | HTML?: string; 57 | Attributes?: Attributes; 58 | } 59 | 60 | export type htmlResponse = string | { [x: string]: string; } | null; 61 | 62 | export type formatter = ((ObjectToParse: NotionObject, options: Options, index: number, ObjectList: Array) => htmlResponse); -------------------------------------------------------------------------------- /src/utils/helper.ts: -------------------------------------------------------------------------------- 1 | import { camelCase, upperFirst } from "lodash-es" 2 | 3 | export const getBlockType = (recordValue: any): string => { 4 | const v = recordValue 5 | if (typeof v.type !== 'undefined') { 6 | return ([v.type] as const).map(camelCase).map(upperFirst)[0] as string 7 | } 8 | if (typeof v.pages !== 'undefined') { 9 | return 'Space' 10 | } 11 | return 'Unkown' 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { NotionAPI } from './NotionAPI' 2 | export * from './helper' 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "jsx": "preserve", 8 | "sourceMap": true, 9 | "resolveJsonModule": true, 10 | "esModuleInterop": true, 11 | "lib": ["esnext", "dom"], 12 | "baseUrl": ".", 13 | "paths": { 14 | "@/*": ["./src/*"] 15 | } 16 | }, 17 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"] 18 | } 19 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { DefinePlugin } = require('webpack') 3 | const { VueLoaderPlugin } = require('vue-loader'); 4 | 5 | const IS_DEV = process.env.NODE_ENV === 'development' 6 | 7 | /** 8 | * @type import('webpack').Configuration 9 | */ 10 | const config = { 11 | mode: IS_DEV ? 'development' : 'production', 12 | entry: './src/main.ts', 13 | output: { 14 | filename: 'no-graph.js', 15 | libraryTarget: 'umd', 16 | path: path.resolve(__dirname, 'dist') 17 | }, 18 | plugins: [ 19 | new VueLoaderPlugin(), 20 | new DefinePlugin({ 21 | __DEV__: IS_DEV, 22 | }), 23 | ], 24 | optimization: { 25 | minimize: false, 26 | }, 27 | module: { 28 | rules: [ 29 | { 30 | test: /\.ts$/, 31 | exclude: /node_modules/, 32 | use: [ 33 | { 34 | loader: 'ts-loader', 35 | options: { 36 | // 指定特定的ts编译配置,为了区分脚本的ts配置 37 | configFile: path.resolve(__dirname, './tsconfig.json'), 38 | appendTsSuffixTo: [/\.vue$/] 39 | } 40 | } 41 | ] 42 | }, 43 | { 44 | test: /\.vue$/, 45 | use: ['vue-loader'] 46 | }, 47 | { 48 | test: /\.css$/, 49 | use: ['style-loader', 'css-loader'] 50 | }, 51 | ] 52 | }, 53 | resolve: { 54 | extensions: ['.js', '.ts'], 55 | alias: { 56 | '@': path.join(__dirname, './src') 57 | } 58 | }, 59 | }; 60 | module.exports = (env) => { 61 | console.log(`${IS_DEV ? 'development' : 'production'}`); 62 | return config; 63 | } 64 | --------------------------------------------------------------------------------