├── .github └── workflows │ └── main.yaml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── example ├── .github │ └── workflows │ │ └── deploy.yml ├── .gitignore ├── example_of_notion_page.json ├── motionlink.config.js ├── package-lock.json ├── package.json └── templates │ └── page.template.md ├── package-lock.json ├── package.json ├── src ├── api_wrappers │ └── motion_link_api.ts ├── cli_utils.ts ├── constants │ ├── cli_files.ts │ └── media_types.ts ├── logger.ts ├── main.ts ├── main_function.ts ├── models │ ├── app_models.ts │ ├── config_models.ts │ └── notion_objects.ts └── services │ ├── associations_service.ts │ ├── build_service.ts │ ├── console_service.ts │ ├── file_name_service.ts │ ├── file_system_service.ts │ ├── git_service.ts │ ├── markdown_service.ts │ ├── media_service.ts │ ├── mustache_service.ts │ ├── notion_service.ts │ └── post_processing_service.ts ├── test ├── build_service.test.ts ├── cli_utils.test.ts ├── markdown_service.test.ts ├── media_service.test.ts ├── mocking_utils.ts ├── notion_service.test.ts ├── post_processing_service.test.ts └── setup.ts ├── tsconfig.json ├── tsconfig.testing.json └── tslint.json /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | # Runs tests and ensures package can be built 2 | 3 | name: Package CI 4 | 5 | on: 6 | push: 7 | branches: [main] 8 | pull_request: 9 | branches: [main] 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [12.x, 14.x, 16.x] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | - run: npm ci 26 | - run: npm test 27 | - run: npm run lint 28 | - run: npm run build -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /lib 3 | 4 | # Public folder in example 5 | public 6 | 7 | # MacOS init file 8 | .DS_Store 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "trailingComma": "all", 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Oreal Solutions 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Motionlink-cli 2 | 3 | Use Notion as a Content Management System for personal websites, portfolios, blogs, business homepages, and other kinds of static websites. 4 | 5 | ## Plain Markdown Support 6 | 7 | ### Supported Notion Blocks 8 | 9 | - [x] Paragraph blocks 10 | - [x] Heading one blocks 11 | - [x] Heading two blocks 12 | - [x] Heading three blocks 13 | - [x] Callout blocks 14 | - [x] Quote blocks 15 | - [x] Bulleted list items 16 | - [x] Numbered list item blocks 17 | - [x] TODO blocks 18 | - [ ] Toggle blocks 19 | - [x] Code blocks 20 | - [x] Child page blocks (Adds link to page) 21 | - [x] Child database blocks (Adds link to database) 22 | - [x] Embed blocks (Adds link to resource) 23 | - [x] Image blocks 24 | - [x] Video blocks (Adds link to video) 25 | - [x] File blocks (Adds link to file) 26 | - [x] PDF blocks (Adds link to pdf) 27 | - [x] Bookmark blocks (Adds link to bookmark) 28 | - [x] Equation blocks 29 | - [x] Divider blocks 30 | - [ ] Table of contents blocks 31 | - [ ] Link preview blocks 32 | - [ ] Template blocks 33 | - [ ] Link to page blocks 34 | - [ ] Synced Block blocks 35 | - [ ] Table blocks 36 | - [ ] Table row blocks 37 | 38 | ### Less likely to be supported blocks 39 | 40 | - Column List and Column blocks 41 | - Breadcrumb blocks 42 | 43 | ## Install 44 | 45 | ```bash 46 | npm install motionlink-cli --save-dev 47 | ``` 48 | 49 | ## Getting started with Motionlink 50 | 51 | See the [Getting started guide](https://motionlink.co/docs/Getting%20started). 52 | 53 | ## Available commands 54 | 55 | ### Motionlink vars 56 | 57 | Motionlink works by connecting a databas in your Notion workspace to a Github repo. This connection is called a link. One link connects one Notion database to one Github repo. Different links are allowed to point to the same Github repo. Each link has an access key that can be used to access the linked Notion database through the Notion API. 58 | 59 | It is the access keys that this CLI tool uses to access your databases to feed content into your static website. Access keys, however, are alphanumeric strings that can be hard for a human to remember. A pair string of `DB_NAME=ACCESS_KEY` is referred to as a Motionlink var. A Motionlink var maps an alias name to a link access key for easy referencing from config files. Motionlink vars can be combined into one string by simply separating them with spaces: `DB_NAME1=ACCESS_KEY1 DB_NAME2=ACCESS_KEY2 ...`. 60 | 61 | Motionlink Vars can be generated from the [Console App](https://app.motionlink.co/) by creating a [new Link](https://motionlink.co/docs/Getting%20started#create-a-link). Selecting a link on the console should enable a "Show Args" button at the top of the page that, when clicked, shows the MotionLink vars that can be used with this CLI to access that link. You can select multiple links before clicking "Show Args" to allow the CLI access to all the databases the links connect. 62 | 63 | An easier way of creating links, however is via the connect command (See below). 64 | 65 | Once you have your Motionlink [config file](https://motionlink.co/docs/CLI), you can use the CLI with the following commands. 66 | 67 | ### Classic build 68 | 69 | The classic build command lets you run your config file by passing your Motionlink vars string on the command line. If working with one link, this command might not be an issue, but for very long Motionlink vars strings it can be hard to read. 70 | 71 | ```bash 72 | npx motionlink {MOTIONLINK_VARS} 73 | ``` 74 | 75 | _Replace {MOTIONLINK_VARS} with the Motionlink vars._ 76 | 77 | ### Build 78 | 79 | The build command runs your config file with the Motionlink vars found in the your `.mlvars` file. 80 | 81 | ```bash 82 | npx motionlink build 83 | ``` 84 | 85 | Say your `.mlvars` file contains the text: 86 | 87 | ```bash 88 | posts=31a49b161d214258bd3c43e83c26f64a bloggerInfo=d2cb2fab93eb49d1bcb02ab4e5f8f4ab 89 | ``` 90 | 91 | Then the above command is equivalent the classic build: 92 | 93 | ```bash 94 | npx motionlink posts=31a49b161d214258bd3c43e83c26f64a bloggerInfo=d2cb2fab93eb49d1bcb02ab4e5f8f4ab 95 | ``` 96 | 97 | ### Connect 98 | 99 | The connect command is a complement to the build command in the sense that it automatically create links for your project from your Notion dashboard and creates the `.mlvars` file for you. This command makes setting up links a lot easier. This command can also setup Netlify hosting for your project which means all you will need to do is push your code and start publishing from Notion. 100 | 101 | ```bash 102 | npx motionlink connect 103 | ``` 104 | 105 | You will be promted for the remote URL to your Github repository as well as whether or not you want to setup Netlify hosting for your project. After collecting all the necessary information, this command will make an OAuth-like request to the Motionlink Console where you will be required to sign into Github, Notion, and optionally Netlify. 106 | 107 | > Netlify OAuth can sometimes not redirect back to the application if you were logged out. If this happens, simply follow the printent link again. It always redirects when you are logged in already. 108 | 109 | This command expects your Notion dashboard to be setup like a [Motionlink Website](https://motionlink.co/docs/Installing%20websites) dashboard. That is, the root page of the dashboard has the following properties: 110 | 111 | 1. The title of the root page should contain the git remote URL for your website 112 | 2. And it needs to list the databases (collections) below the callout block 113 | 114 | An example of such a dashboard can be seen [here](https://oreal-motionlink.notion.site/Team-Blogger-https-github-com-oreal-solutions-team-blogger-template-d8a0a4bd3d32445e871a2250541cee94). 115 | 116 | If you selected to deploy to Netlify, this command will push Netlify deploy secrets as well as Motionlink vars to your Github repo and also add a `deploy.yml` workflow file to your project. This workflow file uses the secrets to build and deploy the website upon a push or when Motionlink reports a [publish event](https://motionlink.co/docs/How%20it%20works) by tagging the repo. 117 | 118 | For more complicated projects, like those that use Jekyll or Hugo, you may need to update the workflow file to install required binaries before running the build command. If you do not plan to host with Netlify, simply opt out of Netlify hosting and setup your own workflow(s). 119 | 120 | ## Contributing 121 | 122 | Feel free to contribute, drop issues and rquest features! We also have a our [discussions](https://github.com/oreal-solutions/motionlink-cli/discussions) tab enabled. Feel free to start conversations. 123 | 124 | ## Author 125 | 126 | [Batandwa Mgutsi](https://github.com/bats64mgutsi) 127 | -------------------------------------------------------------------------------- /example/.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | 2 | name: "Site Deploy" 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | tags: 9 | - 'motion-link-*' 10 | 11 | jobs: 12 | deploy: 13 | name: "Deploy to Netlify" 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - run: | 19 | touch .env 20 | echo NETLIFY_SITE_URL=${{ secrets.NETLIFY_SITE_URL }} >> .env 21 | - run: npm ci && npx motionlink ${{ secrets.MOTION_LINK_VARS }} 22 | - run: npm build 23 | - uses: jsmrcaga/action-netlify-deploy@v1.6.0 24 | with: 25 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 26 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 27 | NETLIFY_DEPLOY_TO_PROD: true 28 | build_directory: "./public" 29 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Motionlink Vars 4 | .mlvars 5 | 6 | 7 | # Motionlink Vars 8 | .mlvars 9 | 10 | 11 | # Motionlink Vars 12 | .mlvars 13 | 14 | 15 | # Motionlink Vars 16 | .mlvars 17 | 18 | 19 | # Motionlink Vars 20 | .mlvars 21 | 22 | 23 | # Motionlink Vars 24 | .mlvars 25 | 26 | 27 | # Motionlink Vars 28 | .mlvars 29 | 30 | 31 | # Motionlink Vars 32 | .mlvars 33 | -------------------------------------------------------------------------------- /example/example_of_notion_page.json: -------------------------------------------------------------------------------- 1 | { 2 | "object": "page", 3 | "id": "fec996a4-7386-41a5-b26a-4de67bb94b6d", 4 | "created_time": "2021-10-01T08:03:00.000Z", 5 | "last_edited_time": "2021-10-16T07:01:00.000Z", 6 | "cover": null, 7 | "icon": null, 8 | "parent": { 9 | "type": "database_id", 10 | "database_id": "ce4b45b7-554b-4c84-a242-f4191d1e558b" 11 | }, 12 | "archived": false, 13 | "properties": { 14 | "Deadline": { "id": "fSY%40", "type": "date", "date": null }, 15 | "Status": { 16 | "id": "tcso", 17 | "type": "select", 18 | "select": { "id": "2", "name": "In progress", "color": "yellow" } 19 | }, 20 | "Name": { 21 | "id": "title", 22 | "type": "title", 23 | "title": [ 24 | { 25 | "type": "text", 26 | "text": { 27 | "content": "Add documentation website", 28 | "link": null 29 | }, 30 | "annotations": { 31 | "bold": false, 32 | "italic": false, 33 | "strikethrough": false, 34 | "underline": false, 35 | "code": false, 36 | "color": "default" 37 | }, 38 | "plain_text": "Add documentation website", 39 | "href": null 40 | } 41 | ] 42 | } 43 | }, 44 | "url": "https://www.notion.so/Add-documentation-website-fec996a4738641a5b26a4de67bb94b6d" 45 | } 46 | -------------------------------------------------------------------------------- /example/motionlink.config.js: -------------------------------------------------------------------------------- 1 | const markdownService = require('motionlink-cli/lib/services/markdown_service'); 2 | const ObjectTransformers = markdownService.ObjectTransformers; 3 | 4 | /** @type {import("motionlink-cli/lib/models/config_models").TemplateRule[]} */ 5 | const rules = [ 6 | { 7 | template: 'templates/page.template.md', 8 | outDir: 'public', 9 | uses: { 10 | database: 'db', 11 | takeOnly: 100, 12 | fetchBlocks: true, 13 | map: (page, ctx) => { 14 | // Setting page._title overwrites the file name for this page, which is the page id by default. 15 | // 16 | // All Notion pages have a title. Users are, however, allowed to change the name for the title property in the 17 | // Notion UI. In this example, the title property is 'Name'. That is, the title column in the database is named 18 | // 'Name' for this example. In a database of authors, for example, you may want the title to be 'Author', in which 19 | // case the way to read the title text would be: 20 | // 21 | // page._title = page.data.properties.Author.title[0].plain_text; 22 | // 23 | // By default the title property is name 'Name'. 24 | page._title = page.data.properties.Name.title[0].plain_text; 25 | 26 | // Use page.otherData to pass computed, or any other, data to template files. 27 | page.otherData.titleMarkdown = '# ' + ObjectTransformers.transform_all(page.data.properties.Name.title); 28 | page.otherData.content = ctx.genMarkdownForBlocks(page.blocks); 29 | 30 | return page; 31 | }, 32 | }, 33 | alsoUses: [], 34 | }, 35 | ]; 36 | 37 | module.exports = rules; 38 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "motionlink-cli": "^0.6.0" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /example/templates/page.template.md: -------------------------------------------------------------------------------- 1 | {{{otherData.titleMarkdown}}} 2 | 3 | {{{otherData.content}}} 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "motionlink-cli", 3 | "version": "0.7.2", 4 | "description": "Making it easy to use Notion as a Content Management system for personal websites, portfolios, blogs, business homepages, and other kinds of static websites.", 5 | "main": "lib/main.js", 6 | "types": "lib/main.d.ts", 7 | "scripts": { 8 | "test": "cross-env TS_NODE_PROJECT=\"tsconfig.testing.json\" mocha -r ts-node/register test/setup.ts test/**/*.ts", 9 | "build": "tsc", 10 | "format": "prettier --write \"*/**/*.tsx\"", 11 | "lint": "tslint -p tsconfig.json", 12 | "prepare": "npm run build", 13 | "prepublishOnly": "npm test && npm run lint", 14 | "preversion": "npm run lint", 15 | "version": "npm run format && git add -A src", 16 | "postversion": "git push && git push --tags", 17 | "prebuild": "npm run clean", 18 | "clean": "rimraf lib/", 19 | "publishLatest": "npm publish", 20 | "publishNext": "npm publish --tag next" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/oreal-solutions/motionlink-cli" 25 | }, 26 | "keywords": [ 27 | "cms", 28 | "portfolio", 29 | "jamstack", 30 | "notion-api", 31 | "notion-blog" 32 | ], 33 | "author": "Oreal Solutions", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/oreal-solutions/motionlink-cli/issues" 37 | }, 38 | "homepage": "https://github.com/oreal-solutions/motionlink-cli#readme", 39 | "files": [ 40 | "lib/**/*", 41 | "LICENSE" 42 | ], 43 | "bin": { 44 | "motionlink": "./lib/main.js" 45 | }, 46 | "devDependencies": { 47 | "@types/chai": "^4.2.22", 48 | "@types/deep-equal-in-any-order": "^1.0.1", 49 | "@types/express": "^4.17.17", 50 | "@types/mocha": "^9.0.0", 51 | "@types/mustache": "^4.1.2", 52 | "@types/prompt-sync": "^4.2.0", 53 | "@types/request": "^2.48.7", 54 | "@types/uuid": "^8.3.3", 55 | "chai": "^4.3.4", 56 | "cross-env": "^7.0.3", 57 | "deep-equal-in-any-order": "^1.1.15", 58 | "mocha": "^9.1.2", 59 | "prettier": "^2.4.1", 60 | "ts-node": "^10.2.1", 61 | "tslint": "^6.1.3", 62 | "tslint-config-prettier": "^1.18.0", 63 | "typescript": "^4.4.3" 64 | }, 65 | "dependencies": { 66 | "@notionhq/client": "^1.0.4", 67 | "@types/openurl": "^1.0.0", 68 | "axios": "^0.23.0", 69 | "express": "^4.18.2", 70 | "get-relative-path": "^1.0.2", 71 | "mustache": "^4.2.0", 72 | "openurl": "github:oreal-solutions/openurl", 73 | "prompt-sync": "^4.2.0", 74 | "request": "^2.88.2", 75 | "rimraf": "^3.0.2", 76 | "save": "^2.4.0", 77 | "simple-git": "^3.19.1", 78 | "uuid": "^8.3.2" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/api_wrappers/motion_link_api.ts: -------------------------------------------------------------------------------- 1 | import { StringResponseBody, Token } from '../models/app_models'; 2 | import axios, { AxiosError } from 'axios'; 3 | 4 | export default class MotionLinkApi { 5 | public getNotionTokenForLink(linkAccessKey: string): Promise { 6 | const accessKeyAsToken: Token = { 7 | token: linkAccessKey, 8 | }; 9 | 10 | return this.callHttpFunction('getNotionTokenForLink', accessKeyAsToken); 11 | } 12 | 13 | public async getNotionDatabaseIdForLink(linkAccessKey: string): Promise { 14 | const accessKeyAsToken: Token = { 15 | token: linkAccessKey, 16 | }; 17 | 18 | const response = await this.callHttpFunction('getNotionDatabaseIdForLink', accessKeyAsToken); 19 | return (response as StringResponseBody).value; 20 | } 21 | 22 | private async callHttpFunction(name: string, body: object): Promise { 23 | const endpoint = `https://us-central1-motionlink-aec23.cloudfunctions.net/cli_tool_service-api/${name}`; 24 | 25 | try { 26 | const response = await axios.post(endpoint, { data: body }); 27 | return (response.data as any).data; 28 | } catch (e) { 29 | const error = e as AxiosError; 30 | if (error.response && error.response.status === 500) { 31 | // Server reports all errors with 500 status code 32 | throw new Error(`${(error.response.data as any).message}`); 33 | } else { 34 | throw e; 35 | } 36 | } 37 | } 38 | 39 | private static _instance: MotionLinkApi; 40 | public static get instance(): MotionLinkApi { 41 | return this._instance ?? (this._instance = new MotionLinkApi()); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/cli_utils.ts: -------------------------------------------------------------------------------- 1 | import { Association } from './models/app_models'; 2 | 3 | export function compileAssociations(source: string): Association[] { 4 | source = source.trim(); 5 | if (source.length === 0) return []; 6 | 7 | const stringAssociations = source.split(' '); 8 | return stringAssociations.map((stringAssociation) => { 9 | const units = stringAssociation.split('='); 10 | return { 11 | key: units[0], 12 | value: units[1], 13 | }; 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/constants/cli_files.ts: -------------------------------------------------------------------------------- 1 | const NETLIFY_DEPLOY = ` 2 | name: "Site Deploy" 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | tags: 9 | - 'motion-link-*' 10 | 11 | jobs: 12 | deploy: 13 | name: "Deploy to Netlify" 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - run: | 19 | touch .env 20 | echo NETLIFY_SITE_URL=\${{ secrets.NETLIFY_SITE_URL }} >> .env 21 | - run: npm ci && npx motionlink \${{ secrets.MOTION_LINK_VARS }} 22 | - run: {{BUILD_COMMAND}} 23 | - uses: jsmrcaga/action-netlify-deploy@v1.6.0 24 | with: 25 | NETLIFY_AUTH_TOKEN: \${{ secrets.NETLIFY_AUTH_TOKEN }} 26 | NETLIFY_SITE_ID: \${{ secrets.NETLIFY_SITE_ID }} 27 | NETLIFY_DEPLOY_TO_PROD: true 28 | build_directory: "{{PUBLIC_FOLDER}}" 29 | `; 30 | 31 | export function getNetlifyDeployWorkflow(buildCommand: string, publicFolder: string): string { 32 | return NETLIFY_DEPLOY.replace('{{BUILD_COMMAND}}', buildCommand).replace('{{PUBLIC_FOLDER}}', publicFolder); 33 | } 34 | -------------------------------------------------------------------------------- /src/constants/media_types.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | // https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types 3 | images: [ 4 | '.apng', 5 | '.avif', 6 | '.gif', 7 | '.jpg', 8 | '.jpeg', 9 | '.jfif', 10 | '.pjpeg', 11 | '.pjp', 12 | '.png', 13 | '.svg', 14 | '.webp', 15 | '.bmp', 16 | '.ico', 17 | '.cur', 18 | '.tif', 19 | '.tiff', 20 | ], 21 | 22 | // https://en.wikipedia.org/wiki/Video_file_format 23 | videos: [ 24 | '.webm', 25 | '.mkv', 26 | '.flv', 27 | '.vob', 28 | '.ogv', 29 | '.ogg', 30 | '.drc', 31 | '.gifv', 32 | '.mng', 33 | '.avi', 34 | '.mov', 35 | '.qt', 36 | '.wvm', 37 | '.yuv', 38 | '.rm', 39 | '.rmvb', 40 | '.viv', 41 | '.asf', 42 | '.amv', 43 | '.mp4', 44 | '.m4p', 45 | '.m4v', 46 | '.mpg', 47 | '.mp2', 48 | '.mpeg', 49 | '.mpe', 50 | '.mpv', 51 | '.mpg', 52 | '.mpeg', 53 | '.m2v', 54 | '.m4v', 55 | '.svi', 56 | '.3gp', 57 | '.3g2', 58 | '.mxf', 59 | '.roq', 60 | '.nsv', 61 | '.flv', 62 | '.f4v', 63 | '.f4p', 64 | '.f4a', 65 | '.f4b', 66 | ], 67 | 68 | // https://en.wikipedia.org/wiki/Audio_file_format#List_of_formats 69 | audio: [ 70 | '.aa', 71 | '.aac', 72 | '.aax', 73 | '.act', 74 | '.aiff', 75 | '.alac', 76 | '.amr', 77 | '.ape', 78 | '.au', 79 | '.awb', 80 | '.dss', 81 | '.dvf', 82 | '.flac', 83 | '.gsm', 84 | '.iklax', 85 | '.ivs', 86 | '.m4a', 87 | '.m4b', 88 | '.mmf', 89 | '.mp3', 90 | '.mpc', 91 | '.msv', 92 | '.nmf', 93 | '.ogg', 94 | '.oga', 95 | '.mogg', 96 | '.opus', 97 | '.ra', 98 | '.rm', 99 | '.raw', 100 | '.rf64', 101 | '.sln', 102 | '.tta', 103 | '.voc', 104 | '.vox', 105 | '.wav', 106 | '.wma', 107 | '.wv', 108 | '.webm', 109 | '.8svx', 110 | '.cda', 111 | ] 112 | }; 113 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | export type Logger = { 2 | logPageFlushed: (pagePath: string) => void; 3 | logWithColor: (text: string) => void; 4 | }; 5 | 6 | let defaultLogger: Logger = { 7 | logPageFlushed: (pagePath) => { 8 | console.log('Flushed: \x1b[36m%s ✔\x1b[0m', pagePath); // cyan 9 | }, 10 | 11 | logWithColor: (text) => { 12 | console.log('\x1b[36m%s\x1b[0m', text); // cyan 13 | } 14 | }; 15 | 16 | export function setMockedLogger(logger: Logger) { 17 | defaultLogger = logger; 18 | } 19 | 20 | export function getLogger(): Logger { 21 | return defaultLogger; 22 | } 23 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import main from './main_function'; 4 | 5 | main(); 6 | -------------------------------------------------------------------------------- /src/main_function.ts: -------------------------------------------------------------------------------- 1 | import { compileAssociations } from './cli_utils'; 2 | import { getLogger } from './logger'; 3 | import { TemplateRule } from './models/config_models'; 4 | import AssociationsService from './services/associations_service'; 5 | import { newBuildService } from './services/build_service'; 6 | import ConsoleService, { Host } from './services/console_service'; 7 | import GitService from './services/git_service'; 8 | import MediaService from './services/media_service'; 9 | import PostProcessingService from './services/post_processing_service'; 10 | import makePrompt from 'prompt-sync'; 11 | import FileSystemService from './services/file_system_service'; 12 | import { getNetlifyDeployWorkflow } from './constants/cli_files'; 13 | 14 | export default async function main() { 15 | const logger = getLogger(); 16 | const mlVarsFilePath = `${process.cwd()}/.mlvars`; 17 | const deployWorkflowFilePath = `${process.cwd()}/.github/workflows/deploy.yml`; 18 | const deployWorkflowParentPath = `${process.cwd()}/.github/workflows`; 19 | const gitignoreFilePath = `${process.cwd()}/.gitignore`; 20 | 21 | if (process.argv.length < 3) throw new Error('Too few arguments passed'); 22 | 23 | if (process.argv[2] === 'connect') { 24 | const prompt = makePrompt({ sigint: true }); 25 | const detectedRemoteUrl = await GitService.instance.findGitRemoteUrl(process.cwd()); 26 | 27 | console.log( 28 | `This command lets you connect your Notion workspace to your Github repository. To find the databases to connect, ` + 29 | `Motionlink will look for a Notion page whose title contains the text '${detectedRemoteUrl}'. If this is not the repo you want ` + 30 | `to connect, enter the git remote URL for the repo you want to connect (without .git at the end).\n`, 31 | ); 32 | 33 | let remoteUrl = prompt( 34 | `Enter the url of the repo to connect. Press enter to skip and use '${detectedRemoteUrl}': `, 35 | ); 36 | remoteUrl = remoteUrl.length === 0 ? detectedRemoteUrl : remoteUrl; 37 | 38 | const useNetlify = 39 | prompt('Would you like to configure this project to deploy to Netlify? [y/n]: ').toLowerCase() === 'y'; 40 | const host = useNetlify ? Host.netlify : Host.none; 41 | 42 | let buildCommand = 'npm build'; 43 | let publicFolder = './public'; 44 | if (useNetlify) { 45 | let input = prompt(`What is the command used to build your project? [${buildCommand}]: `); 46 | buildCommand = input.length === 0 ? buildCommand : input; 47 | 48 | input = prompt(`What is your site public folder? [${publicFolder}]: `); 49 | publicFolder = input.length === 0 ? publicFolder : input; 50 | } 51 | 52 | console.log('Follow the link below to authorize the connect request:'); 53 | const connectResult = await ConsoleService.instance.connect(remoteUrl, host, process.env.ML_CONSOLE); 54 | 55 | console.log( 56 | `Connect request complete. ${ 57 | useNetlify 58 | ? 'Site available at below URL. Note that initially the Netlify site will be empty. It will be populated once you push your code.' 59 | : '' 60 | }`, 61 | ); 62 | if (useNetlify) { 63 | logger.logWithColor(connectResult.secureUrl ?? ''); 64 | } 65 | 66 | console.log('Saving Motionlink vars...'); 67 | FileSystemService.instance.writeStringToFile(connectResult.vars, mlVarsFilePath); 68 | 69 | let gitignore = ''; 70 | try { 71 | gitignore = FileSystemService.instance.readFileAsString(gitignoreFilePath); 72 | } catch (e) { 73 | console.log('... No .gitignore file. Will create one.'); 74 | } 75 | 76 | gitignore = `${gitignore}\n\n# Motionlink Vars\n.mlvars\n`; 77 | FileSystemService.instance.writeStringToFile(gitignore, gitignoreFilePath); 78 | 79 | if (useNetlify) { 80 | console.log('Saving github workflows...'); 81 | if (!FileSystemService.instance.doesFolderExist(deployWorkflowParentPath)) { 82 | FileSystemService.instance.createFolder(deployWorkflowParentPath); 83 | } 84 | 85 | FileSystemService.instance.writeStringToFile( 86 | getNetlifyDeployWorkflow(buildCommand, publicFolder), 87 | deployWorkflowFilePath, 88 | ); 89 | } 90 | 91 | console.log('All Done!'); 92 | process.exit(0); 93 | } else { 94 | let motionlinkVars = [...process.argv].splice(2).join(' '); 95 | if (process.argv[2] === 'build') { 96 | motionlinkVars = FileSystemService.instance.readFileAsString(mlVarsFilePath).trim(); 97 | } 98 | 99 | const associations = compileAssociations(motionlinkVars); 100 | const dbAssociations = await AssociationsService.instance.toNotionDatabaseAssociations(associations); 101 | 102 | const configFile = `${process.cwd()}/motionlink.config.js`; 103 | const templateRules: TemplateRule[] = require(configFile); 104 | 105 | await newBuildService().build(templateRules, dbAssociations); 106 | await MediaService.instance.commit(); 107 | PostProcessingService.instance.flush(); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/models/app_models.ts: -------------------------------------------------------------------------------- 1 | export type Token = { 2 | token: string; 3 | }; 4 | export type Association = { 5 | key: string; 6 | value: string; 7 | }; 8 | 9 | export type NotionDatabaseAssociation = { 10 | name: string; 11 | notionDatabaseId: string; 12 | notionIntegrationToken: Token; 13 | }; 14 | 15 | 16 | // API Models 17 | 18 | export type StringResponseBody = { 19 | value: string; 20 | }; 21 | 22 | export type ConnectResult = { 23 | secureUrl?: string, 24 | vars: string, 25 | } -------------------------------------------------------------------------------- /src/models/config_models.ts: -------------------------------------------------------------------------------- 1 | import { GetBlockResponse, GetDatabaseResponse, GetPageResponse } from '@notionhq/client/build/src/api-endpoints'; 2 | import { FileObject } from './notion_objects'; 3 | 4 | export type NotionBlock = { 5 | data: GetBlockResponse; 6 | children: NotionBlock[]; 7 | }; 8 | 9 | export type NotionPage = { 10 | otherData: Record; 11 | data: GetPageResponse; 12 | blocks: NotionBlock[]; 13 | 14 | /** 15 | * This value should be retrieved by users in DatabaseRule.map. 16 | * Default is page id. 17 | */ 18 | _title?: string; 19 | }; 20 | 21 | export type NotionDatabase = { 22 | pages: NotionPage[]; 23 | data: GetDatabaseResponse; 24 | }; 25 | 26 | export type Context = { 27 | /** 28 | * Object containing the `NotionDatabase`s pulled with the `DatabaseRule`s 29 | * in `alsoUses` of this `TemplateRule`. 30 | * 31 | * For example, `others.abc` will contain the `NotionDatabase` fetched by 32 | * the `DatabaseRule` in this `TemplateRule.alsoUses` whose `database` field is 33 | * `abc`. It will be undefined if there is no such `DatabaseRule`. 34 | */ 35 | others: Record; 36 | 37 | /** 38 | * Transforms the given blocks to markdown with the `BlockTransformers` in 39 | * `services/markdown_service`. 40 | * 41 | * The output of this function is nor formatted. 42 | * 43 | * You can modify the `BlockTransformers` and `ObjectTransformers` objects 44 | * to provide custom transformers or otherwise add new transformers. For 45 | * example, running `ObjectTransformers.equation = (object) => 'No Inline Equations'` 46 | * will cause `genMarkdownForBlocks` to render `'No Inline Equations'` whenever an inline 47 | * equation object is encountered while generating the markdown. 48 | * 49 | * Likewise, modify `BlockTransformers` to provide custom transformers for entire blocks. 50 | * For example: 51 | * 52 | * @example 53 | * const markdownService = require('motionlink-cli/lib/services/markdown_service'); 54 | * const ObjectTransformers = markdownService.ObjectTransformers; 55 | * 56 | * ObjectTransformers.equation = (block) => '**No Equations allowed on this site!**'; 57 | * 58 | * // ... 59 | */ 60 | genMarkdownForBlocks: (blocks: NotionBlock[]) => string; 61 | 62 | /** 63 | * Returns the download url and caption for the given FileObject. 64 | * 65 | * If the media is hosted by Notion, it will be downloaded to a media folder in the 66 | * `outDir` of this TemplateRule and the returned `src` value will be the asset path 67 | * in `outDir`, else the file url will be returned as is given in the media object. 68 | * 69 | * The return value of this function is not affected by the value of 70 | * `TemplateRule.writeMediaTo`. 71 | */ 72 | fetchMedia: (object: FileObject) => { src: string; captionMarkdown: string }; 73 | }; 74 | 75 | type T = 76 | | { 77 | property: string; 78 | direction: 'ascending' | 'descending'; 79 | } 80 | | { 81 | timestamp: 'created_time' | 'last_edited_time'; 82 | direction: 'ascending' | 'descending'; 83 | }; 84 | 85 | export type SortsParams = T[]; 86 | 87 | export type DatabaseRule = { 88 | database: string; 89 | fetchBlocks?: boolean; 90 | takeOnly?: number; 91 | map?: (notionPage: NotionPage, context: Context) => NotionPage; 92 | sort?: SortsParams; 93 | 94 | /** 95 | * See: https://developers.notion.com/reference/post-database-query#post-database-query-filter 96 | */ 97 | filter?: object; 98 | }; 99 | 100 | export type TemplateRule = { 101 | template: string; 102 | outDir: string; 103 | uses: DatabaseRule; 104 | alsoUses: DatabaseRule[]; 105 | 106 | /** 107 | * The folder to write media assets to. 108 | * 109 | * By default media is placed in the same folder as the page requesting it. Setting this field 110 | * forces the media to be placed in this folder. 111 | * 112 | * Do note, however, that `Context.fetchMedia` will will return the path of the media 113 | * as if it were in the same folder as the page requesting it, i.e setting this value 114 | * does not affect the return value of `Context.fetchMedia`. 115 | */ 116 | writeMediaTo?: string; 117 | }; 118 | -------------------------------------------------------------------------------- /src/models/notion_objects.ts: -------------------------------------------------------------------------------- 1 | // All the types in this file were copied from: 2 | // https://github.com/makenotion/notion-sdk-js/blob/main/src/api-endpoints.ts 3 | 4 | export type IdRequest = string | string; 5 | export type TextRequest = string; 6 | 7 | // See: https://developers.notion.com/reference/rich-text#text-objects 8 | export type TextObject = { 9 | type: 'text'; 10 | text: { 11 | content: string; 12 | link: { 13 | url: TextRequest; 14 | } | null; 15 | }; 16 | annotations: { 17 | bold: boolean; 18 | italic: boolean; 19 | strikethrough: boolean; 20 | underline: boolean; 21 | code: boolean; 22 | color: 23 | | 'default' 24 | | 'gray' 25 | | 'brown' 26 | | 'orange' 27 | | 'yellow' 28 | | 'green' 29 | | 'blue' 30 | | 'purple' 31 | | 'pink' 32 | | 'red' 33 | | 'gray_background' 34 | | 'brown_background' 35 | | 'orange_background' 36 | | 'yellow_background' 37 | | 'green_background' 38 | | 'blue_background' 39 | | 'purple_background' 40 | | 'pink_background' 41 | | 'red_background'; 42 | }; 43 | plain_text: string; 44 | href: string | null; 45 | }; 46 | 47 | // See: https://developers.notion.com/reference/rich-text#mention-objects 48 | export type MentionObject = { 49 | type: 'mention'; 50 | mention: UserObject | DateObject | PageMentionObject | DatabaseMentionObject; 51 | annotations: { 52 | bold: boolean; 53 | italic: boolean; 54 | strikethrough: boolean; 55 | underline: boolean; 56 | code: boolean; 57 | color: 58 | | 'default' 59 | | 'gray' 60 | | 'brown' 61 | | 'orange' 62 | | 'yellow' 63 | | 'green' 64 | | 'blue' 65 | | 'purple' 66 | | 'pink' 67 | | 'red' 68 | | 'gray_background' 69 | | 'brown_background' 70 | | 'orange_background' 71 | | 'yellow_background' 72 | | 'green_background' 73 | | 'blue_background' 74 | | 'purple_background' 75 | | 'pink_background' 76 | | 'red_background'; 77 | }; 78 | plain_text: string; 79 | href: string | null; 80 | }; 81 | 82 | export type UserObject = { 83 | type: 'user'; 84 | user: 85 | | { 86 | id: IdRequest; 87 | object: 'user'; 88 | } 89 | | { 90 | type: 'person'; 91 | person: { 92 | email: string; 93 | }; 94 | name: string | null; 95 | avatar_url: string | null; 96 | id: IdRequest; 97 | object: 'user'; 98 | } 99 | | { 100 | type: 'bot'; 101 | bot: 102 | | Record 103 | | { 104 | owner: 105 | | { 106 | type: 'user'; 107 | user: 108 | | { 109 | type: 'person'; 110 | person: { 111 | email: string; 112 | }; 113 | name: string | null; 114 | avatar_url: string | null; 115 | id: IdRequest; 116 | object: 'user'; 117 | } 118 | | { 119 | id: IdRequest; 120 | object: 'user'; 121 | }; 122 | } 123 | | { 124 | type: 'workspace'; 125 | workspace: true; 126 | }; 127 | }; 128 | name: string | null; 129 | avatar_url: string | null; 130 | id: IdRequest; 131 | object: 'user'; 132 | }; 133 | }; 134 | 135 | export type DateObject = { 136 | type: 'date'; 137 | date: { 138 | start: string; 139 | end: string | null; 140 | }; 141 | }; 142 | 143 | export type PageMentionObject = { 144 | type: 'page'; 145 | page: { 146 | id: IdRequest; 147 | }; 148 | }; 149 | 150 | export type DatabaseMentionObject = { 151 | type: 'database'; 152 | database: { 153 | id: IdRequest; 154 | }; 155 | }; 156 | 157 | // See: https://developers.notion.com/reference/rich-text#equation-objects 158 | export type EquationObject = { 159 | type: 'equation'; 160 | equation: { 161 | expression: TextRequest; 162 | }; 163 | annotations: { 164 | bold: boolean; 165 | italic: boolean; 166 | strikethrough: boolean; 167 | underline: boolean; 168 | code: boolean; 169 | color: 170 | | 'default' 171 | | 'gray' 172 | | 'brown' 173 | | 'orange' 174 | | 'yellow' 175 | | 'green' 176 | | 'blue' 177 | | 'purple' 178 | | 'pink' 179 | | 'red' 180 | | 'gray_background' 181 | | 'brown_background' 182 | | 'orange_background' 183 | | 'yellow_background' 184 | | 'green_background' 185 | | 'blue_background' 186 | | 'purple_background' 187 | | 'pink_background' 188 | | 'red_background'; 189 | }; 190 | plain_text: string; 191 | href: string | null; 192 | }; 193 | 194 | export type FileObject = 195 | | { 196 | type: 'external'; 197 | external: { 198 | url: string; 199 | }; 200 | caption: Array; 201 | } 202 | | { 203 | type: 'file'; 204 | file: { 205 | url: string; 206 | expiry_time: string; 207 | }; 208 | caption: Array; 209 | }; 210 | -------------------------------------------------------------------------------- /src/services/associations_service.ts: -------------------------------------------------------------------------------- 1 | import MotionLinkApi from '../api_wrappers/motion_link_api'; 2 | import { Association, NotionDatabaseAssociation, Token } from '../models/app_models'; 3 | 4 | export default class AssociationsService { 5 | public async toNotionDatabaseAssociations(associations: Association[]): Promise { 6 | const promises = new Array< 7 | Promise<{ data: { notionDatabaseId: string; notionIntegrationToken: Token }; key: string }> 8 | >(); 9 | 10 | const makeAssociationData = async (association: Association) => { 11 | return { 12 | data: await this.getLinkData(association.value), 13 | key: association.key, 14 | }; 15 | }; 16 | 17 | for (const association of associations) { 18 | promises.push(makeAssociationData(association)); 19 | } 20 | 21 | const data = await Promise.all(promises); 22 | return data.map((linkData) => { 23 | return { 24 | name: linkData.key, 25 | notionDatabaseId: linkData.data.notionDatabaseId, 26 | notionIntegrationToken: linkData.data.notionIntegrationToken, 27 | }; 28 | }); 29 | } 30 | 31 | private async getLinkData( 32 | linkAccessKey: string, 33 | ): Promise<{ notionDatabaseId: string; notionIntegrationToken: Token }> { 34 | const promises = [ 35 | MotionLinkApi.instance.getNotionDatabaseIdForLink(linkAccessKey), 36 | MotionLinkApi.instance.getNotionTokenForLink(linkAccessKey), 37 | ]; 38 | 39 | const linkData = await Promise.all(promises as any[]); 40 | return { 41 | notionDatabaseId: linkData[0], 42 | notionIntegrationToken: linkData[1], 43 | }; 44 | } 45 | 46 | private static _instance: AssociationsService; 47 | public static get instance(): AssociationsService { 48 | return this._instance ?? (this._instance = new AssociationsService()); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/services/build_service.ts: -------------------------------------------------------------------------------- 1 | import { GetBlockResponse, GetPageResponse } from '@notionhq/client/build/src/api-endpoints'; 2 | import { NotionDatabaseAssociation, Token } from '../models/app_models'; 3 | import { Context, DatabaseRule, NotionBlock, NotionDatabase, NotionPage, TemplateRule } from '../models/config_models'; 4 | import NotionService from './notion_service'; 5 | import FileSystemService from './file_system_service'; 6 | import MustacheService from './mustache_service'; 7 | import MarkdownService, { getMedia } from './markdown_service'; 8 | import PostProcessingService from './post_processing_service'; 9 | 10 | export interface BuildService { 11 | build: (templateRules: TemplateRule[], databaseAssociations: NotionDatabaseAssociation[]) => Promise; 12 | } 13 | 14 | export function newBuildService(): BuildService { 15 | return new BuildServiceImpl(); 16 | } 17 | 18 | class BuildServiceImpl implements BuildService { 19 | public async build(templateRules: TemplateRule[], databaseAssociations: NotionDatabaseAssociation[]): Promise { 20 | for (const rule of templateRules) { 21 | await this.makeTemplateRuleBuilder().build(rule, databaseAssociations); 22 | } 23 | } 24 | 25 | private makeTemplateRuleBuilder(): TemplateRuleBuilder { 26 | const fileExtensionFinder = new FileExtensionFinder(); 27 | const cachingFileReader = new CachingFileReader(); 28 | const templateRuleOutputWriter = new TemplateRuleOutputWriter() 29 | .setFileExtensionFinder(fileExtensionFinder) 30 | .setCachingFileReader(cachingFileReader); 31 | 32 | const blockChildrenFetcher = new BlockChildrenFetcher(); 33 | const databaseFetcher = new DatabaseFetcher().setBlockChildrenFether(blockChildrenFetcher); 34 | 35 | const databaseAssociationFinder = new DatabaseAssociationFinder(); 36 | const secondaryDatabasesFetcher = new SecondaryDatabasesFetcher() 37 | .setDatabaseFetcher(databaseFetcher) 38 | .setDatabaseAssociationFinder(databaseAssociationFinder); 39 | 40 | return new TemplateRuleBuilder() 41 | .setSecondaryDatabasesFetcher(secondaryDatabasesFetcher) 42 | .setDatabaseFetcher(databaseFetcher) 43 | .setTemplateRuleOutputWriter(templateRuleOutputWriter) 44 | .setDatabaseAssociationFinder(databaseAssociationFinder); 45 | } 46 | } 47 | 48 | export class FileExtensionFinder { 49 | public findFileExtensionOf(path: string): string { 50 | const index = path.lastIndexOf('.'); 51 | if (index < 0) return ''; 52 | 53 | return path.substring(index); 54 | } 55 | } 56 | 57 | export class DatabaseAssociationFinder { 58 | public findDatabaseAssociationFor( 59 | rule: DatabaseRule, 60 | databaseAssociations: NotionDatabaseAssociation[], 61 | ): NotionDatabaseAssociation { 62 | const filtered = databaseAssociations.filter((association) => association.name === rule.database); 63 | if (filtered.length === 0) throw new Error(`The database association "${rule.database}" does not exist.`); 64 | 65 | return filtered[0]; 66 | } 67 | } 68 | 69 | export class CachingFileReader { 70 | public readAsString(path: string): string { 71 | if (this.fileChache.get(path)) return this.fileChache.get(path) as string; 72 | 73 | const text = FileSystemService.instance.readFileAsString(path); 74 | this.fileChache.set(path, text); 75 | return text; 76 | } 77 | 78 | private fileChache: Map = new Map(); 79 | } 80 | 81 | export class TemplateRuleOutputWriter { 82 | public async write(page: NotionPage, pageTemplateRule: TemplateRule): Promise { 83 | const templateFileContents = this.fileReader!.readAsString(pageTemplateRule.template); 84 | const out = MustacheService.instance.render(page, templateFileContents); 85 | 86 | if (!FileSystemService.instance.doesFolderExist(pageTemplateRule.outDir)) { 87 | FileSystemService.instance.createFolder(pageTemplateRule.outDir); 88 | } 89 | 90 | const outFilePath = 91 | pageTemplateRule.outDir + 92 | '/' + 93 | page._title + 94 | this.fileExtensionFinder!.findFileExtensionOf(pageTemplateRule.template); 95 | 96 | PostProcessingService.instance.submit(out, outFilePath, page.data.id); 97 | } 98 | 99 | public setFileExtensionFinder(fileExtensionFinder: FileExtensionFinder) { 100 | this.fileExtensionFinder = fileExtensionFinder; 101 | return this; 102 | } 103 | 104 | public setCachingFileReader(reader: CachingFileReader) { 105 | this.fileReader = reader; 106 | return this; 107 | } 108 | 109 | private fileReader?: CachingFileReader; 110 | private fileExtensionFinder?: FileExtensionFinder; 111 | } 112 | 113 | export class BlockChildrenFetcher { 114 | public async fetchChildren(bId: string, notionToken: Token): Promise { 115 | const promises = new Array>(); 116 | for await (const child of NotionService.instance.getBlockChildren({ 117 | blockId: bId, 118 | withToken: notionToken, 119 | })) { 120 | promises.push(this._parseBlock(child, notionToken)); 121 | } 122 | 123 | return Promise.all(promises); 124 | } 125 | 126 | private async _parseBlock(blockData: GetBlockResponse, notionToken: Token): Promise { 127 | if ((blockData as any).has_children) { 128 | return { 129 | data: blockData, 130 | children: await this.fetchChildren(blockData.id, notionToken), 131 | }; 132 | } else { 133 | return { 134 | data: blockData, 135 | children: [], 136 | }; 137 | } 138 | } 139 | } 140 | 141 | export class DatabaseFetcher { 142 | public async fetchDatabase(args: { 143 | databaseRule: DatabaseRule; 144 | association: NotionDatabaseAssociation; 145 | context: Context; 146 | onPostPageMapping: (notionPage: NotionPage) => Promise; 147 | }): Promise { 148 | const database = await NotionService.instance.getDatabase({ 149 | withId: args.association.notionDatabaseId, 150 | withToken: args.association.notionIntegrationToken, 151 | }); 152 | 153 | const pagesData = NotionService.instance.queryForDatabasePages({ 154 | databaseId: args.association.notionDatabaseId, 155 | withToken: args.association.notionIntegrationToken, 156 | takeOnly: args.databaseRule.takeOnly, 157 | sort: args.databaseRule.sort, 158 | filter: args.databaseRule.filter, 159 | }); 160 | 161 | const promises = new Array>(); 162 | 163 | const fetchPage = async (pageData: GetPageResponse): Promise => { 164 | let pageBlocks: NotionBlock[] = []; 165 | if (Boolean(args.databaseRule.fetchBlocks)) { 166 | pageBlocks = await this.blockChildrenFetcher!.fetchChildren( 167 | pageData.id, 168 | args.association.notionIntegrationToken, 169 | ); 170 | } 171 | 172 | let page: NotionPage = { 173 | _title: pageData.id, 174 | otherData: {}, 175 | data: pageData, 176 | blocks: pageBlocks, 177 | }; 178 | 179 | if (args.databaseRule.map) { 180 | page = args.databaseRule.map(page, args.context); 181 | } 182 | 183 | await args.onPostPageMapping(page); 184 | return page; 185 | }; 186 | 187 | for await (const pageData of pagesData) { 188 | promises.push(fetchPage(pageData)); 189 | } 190 | 191 | const outPages = await Promise.all(promises); 192 | 193 | return { 194 | data: database, 195 | pages: outPages, 196 | }; 197 | } 198 | 199 | public setBlockChildrenFether(blockChildrenFetcher: BlockChildrenFetcher) { 200 | this.blockChildrenFetcher = blockChildrenFetcher; 201 | return this; 202 | } 203 | 204 | private blockChildrenFetcher: BlockChildrenFetcher | undefined; 205 | } 206 | 207 | export class SecondaryDatabasesFetcher { 208 | public async fetchAll( 209 | databaseRules: DatabaseRule[], 210 | databaseAssociations: NotionDatabaseAssociation[], 211 | ctx: Context, 212 | ): Promise> { 213 | const others: any = {}; 214 | const promises = new Array>(); 215 | 216 | for (const dbRule of databaseRules) { 217 | const dbAssociation = this.databaseAssociationFinder!.findDatabaseAssociationFor(dbRule, databaseAssociations); 218 | 219 | promises.push( 220 | new Promise((resolve, reject) => { 221 | this.databaseFetcher!.fetchDatabase({ 222 | databaseRule: dbRule, 223 | association: dbAssociation, 224 | context: ctx, 225 | onPostPageMapping: async (_) => ({} as any), 226 | }) 227 | .then((database) => 228 | resolve({ 229 | rule: dbRule, 230 | db: database, 231 | }), 232 | ) 233 | .catch((e) => reject(e)); 234 | }), 235 | ); 236 | } 237 | 238 | for (const value of await Promise.all(promises)) { 239 | others[value.rule.database] = value.db; 240 | } 241 | 242 | return others; 243 | } 244 | 245 | public setDatabaseFetcher(databaseFetcher: DatabaseFetcher) { 246 | this.databaseFetcher = databaseFetcher; 247 | return this; 248 | } 249 | 250 | public setDatabaseAssociationFinder(finder: DatabaseAssociationFinder) { 251 | this.databaseAssociationFinder = finder; 252 | return this; 253 | } 254 | 255 | private databaseFetcher: DatabaseFetcher | undefined; 256 | private databaseAssociationFinder: DatabaseAssociationFinder | undefined; 257 | } 258 | 259 | export class TemplateRuleBuilder { 260 | public async build(rule: TemplateRule, databaseAssociations: NotionDatabaseAssociation[]): Promise { 261 | const ctx: Context = { 262 | others: {}, 263 | genMarkdownForBlocks: (blocks) => MarkdownService.instance.genMarkdownForBlocks(blocks, rule), 264 | fetchMedia: (fileObject) => getMedia(fileObject, rule), 265 | }; 266 | 267 | ctx.others = await this.secondaryDatabasesFetcher!.fetchAll(rule.alsoUses, databaseAssociations, ctx); 268 | const primaryAssociation = this.databaseAssociationFinder!.findDatabaseAssociationFor( 269 | rule.uses, 270 | databaseAssociations, 271 | ); 272 | 273 | await this.databaseFetcher?.fetchDatabase({ 274 | databaseRule: rule.uses, 275 | association: primaryAssociation, 276 | context: ctx, 277 | onPostPageMapping: async (notionPage) => { 278 | await this.templateRuleOutputWriter!.write(notionPage, rule); 279 | return notionPage; 280 | }, 281 | }); 282 | } 283 | 284 | public setSecondaryDatabasesFetcher(secondaryDatabasesFetcher: SecondaryDatabasesFetcher) { 285 | this.secondaryDatabasesFetcher = secondaryDatabasesFetcher; 286 | return this; 287 | } 288 | 289 | public setDatabaseFetcher(databaseFetcher: DatabaseFetcher) { 290 | this.databaseFetcher = databaseFetcher; 291 | return this; 292 | } 293 | 294 | public setTemplateRuleOutputWriter(writer: TemplateRuleOutputWriter) { 295 | this.templateRuleOutputWriter = writer; 296 | return this; 297 | } 298 | 299 | public setDatabaseAssociationFinder(finder: DatabaseAssociationFinder) { 300 | this.databaseAssociationFinder = finder; 301 | return this; 302 | } 303 | 304 | private secondaryDatabasesFetcher: SecondaryDatabasesFetcher | undefined; 305 | private databaseFetcher: DatabaseFetcher | undefined; 306 | private templateRuleOutputWriter: TemplateRuleOutputWriter | undefined; 307 | private databaseAssociationFinder: DatabaseAssociationFinder | undefined; 308 | } 309 | -------------------------------------------------------------------------------- /src/services/console_service.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { Server } from 'http'; 3 | import { ConnectResult } from '../models/app_models'; 4 | 5 | import openurl from 'openurl'; 6 | import { getLogger } from '../logger'; 7 | 8 | const HANDLER_PORT = 8080; 9 | 10 | export enum Host { 11 | none = 'none', 12 | netlify = 'netlify', 13 | } 14 | 15 | export default class ConsoleService { 16 | private readonly logger = getLogger(); 17 | 18 | /** 19 | * Makes a connect request to the Motionlink Console. 20 | * 21 | * Returns the Motionlink vars and host site url string on success, throws on error. 22 | * 23 | * If given host was none, the returned site url is empty. 24 | */ 25 | public async connect(githubRepoUrl: string, host: Host, consoleUrl?: string): Promise { 26 | return new Promise((resolve, reject) => { 27 | const app = express(); 28 | if (!Boolean(consoleUrl)) { 29 | consoleUrl = 'https://app.motionlink.co'; 30 | } 31 | 32 | let server: Server; 33 | app.get('/callback', (req, res) => { 34 | const status = req.query.status === 'true'; 35 | if (status) { 36 | resolve({ 37 | secureUrl: req.query.secureUrl as any, 38 | vars: req.query.vars as any, 39 | }); 40 | } else { 41 | reject(req.query.message); 42 | } 43 | 44 | res.redirect(`${consoleUrl}/connect_completed`); 45 | setTimeout(() => server.close(), 2000); 46 | }); 47 | 48 | server = app.listen(HANDLER_PORT, async () => { 49 | const redirectUri = `http://localhost:${HANDLER_PORT}/callback`; 50 | 51 | const queryUrl = new URL(`${consoleUrl!}/market`); 52 | queryUrl.searchParams.append('source', githubRepoUrl); 53 | queryUrl.searchParams.append('host', host); 54 | queryUrl.searchParams.append('redirect_uri', redirectUri); 55 | queryUrl.searchParams.append('q', 'connect'); 56 | 57 | this.logger.logWithColor(queryUrl.href); 58 | openurl.open(queryUrl.href); 59 | }); 60 | }); 61 | } 62 | 63 | private static _instance: ConsoleService; 64 | public static get instance(): ConsoleService { 65 | return this._instance ?? (this._instance = new ConsoleService()); 66 | } 67 | 68 | public static setMockedInstance(instance: ConsoleService) { 69 | this._instance = instance; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/services/file_name_service.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from 'uuid'; 2 | 3 | export default class FileNameService { 4 | public genUnique(): string { 5 | return v4(); 6 | } 7 | 8 | private static _instance: FileNameService; 9 | public static get instance(): FileNameService { 10 | return this._instance ?? (this._instance = new FileNameService()); 11 | } 12 | 13 | public static setMockedInstance(instance: FileNameService) { 14 | this._instance = instance; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/file_system_service.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | 3 | export default class FileSystemService { 4 | public readFileAsString(path: string): string { 5 | return fs.readFileSync(path).toString(); 6 | } 7 | 8 | public writeStringToFile(data: string, path: string): void { 9 | fs.writeFileSync(path, data); 10 | } 11 | 12 | public doesFolderExist(path: string): boolean { 13 | return fs.existsSync(path); 14 | } 15 | 16 | public createFolder(path: string): void { 17 | fs.mkdirSync(path, { 18 | recursive: true, 19 | }); 20 | } 21 | 22 | private static _instance: FileSystemService; 23 | public static get instance(): FileSystemService { 24 | return this._instance ?? (this._instance = new FileSystemService()); 25 | } 26 | 27 | public static setMockedInstance(instance: FileSystemService) { 28 | this._instance = instance; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/services/git_service.ts: -------------------------------------------------------------------------------- 1 | import git from 'simple-git'; 2 | 3 | export default class GitService { 4 | /** 5 | * Returns the git remote url for the given folder. 6 | * 7 | * Yields empty string if non exists. 8 | */ 9 | public async findGitRemoteUrl(path: string): Promise { 10 | const res = await git({ 11 | baseDir: path, 12 | }).getConfig('remote.origin.url'); 13 | 14 | return res.value ?? ''; 15 | } 16 | 17 | private static _instance: GitService; 18 | public static get instance(): GitService { 19 | return this._instance ?? (this._instance = new GitService()); 20 | } 21 | 22 | public static setMockedInstance(instance: GitService) { 23 | this._instance = instance; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/services/markdown_service.ts: -------------------------------------------------------------------------------- 1 | import { GetBlockResponse } from '@notionhq/client/build/src/api-endpoints'; 2 | import { NotionBlock, TemplateRule } from '../models/config_models'; 3 | import { EquationObject, FileObject, MentionObject, TextObject } from '../models/notion_objects'; 4 | import MediaService from './media_service'; 5 | 6 | export function applyAnnotations( 7 | text: string, 8 | annotations: { 9 | bold: boolean; 10 | italic: boolean; 11 | strikethrough: boolean; 12 | code: boolean; 13 | }, 14 | ): string { 15 | let out = text; 16 | 17 | if (annotations.code) out = `\`${out}\``; 18 | if (annotations.strikethrough) out = `~~${out}~~`; 19 | if (annotations.italic) out = `*${out}*`; 20 | if (annotations.bold) out = `**${out}**`; 21 | 22 | return out; 23 | } 24 | 25 | function transformAllObjectsWithPrefix( 26 | objects: Array, 27 | prefix: string, 28 | ): string { 29 | return `${prefix}${ObjectTransformers.transform_all(objects)}`; 30 | } 31 | 32 | /** 33 | * @visibleForTesting 34 | */ 35 | export function getMedia(object: FileObject, rule: TemplateRule): { src: string; captionMarkdown: string } { 36 | const captionMd = transformAllObjectsWithPrefix(object.caption, ''); 37 | let source = ''; 38 | 39 | if (object.type === 'external') { 40 | source = object.external.url; 41 | } else { 42 | source = MediaService.instance.stageFetchRequest(object.file.url, rule); 43 | } 44 | 45 | return { 46 | src: source, 47 | captionMarkdown: captionMd, 48 | }; 49 | } 50 | 51 | /** 52 | * The object transformers. 53 | * 54 | * The keys are the object types and the value is the object type to 55 | * Markdown transformer. 56 | * 57 | * These can be overwritten to provide custom implementations. 58 | * 59 | * See all object types here: src/models/notion_objects.ts 60 | */ 61 | export const ObjectTransformers = { 62 | text: (object: TextObject): string => { 63 | let out = object.text.content; 64 | if (object.text.link) out = `[${out}](${object.text.link.url})`; 65 | 66 | return applyAnnotations(out, object.annotations); 67 | }, 68 | 69 | mention: (object: MentionObject): string => { 70 | let out = ''; 71 | 72 | if (object.mention.type === 'user') { 73 | const user = object.mention.user; 74 | if ((user as any).type === 'person' || (user as any).type === 'bot') { 75 | out = (user as any).name; 76 | } else { 77 | out = object.plain_text; 78 | } 79 | 80 | if (object.href) out = `[${out}](${object.href})`; 81 | } else if (object.mention.type === 'date') { 82 | const date = object.mention.date; 83 | if (date.end != null) out = `${date.start} to ${date.end}`; 84 | else out = date.start; 85 | 86 | if (object.href) out = `[${out}](${object.href})`; 87 | } else if (object.mention.type === 'page') { 88 | const page = object.mention.page; 89 | out = `[${object.plain_text}](:::pathTo:::${page.id}:::)`; 90 | } else if (object.mention.type === 'database') { 91 | out = `[${object.plain_text}](${object.href})`; 92 | } 93 | 94 | return applyAnnotations(out, object.annotations); 95 | }, 96 | 97 | equation: (object: EquationObject): string => { 98 | let out = `$${object.equation.expression}$`; 99 | if (object.href) out = `[${out}](${object.href})`; 100 | 101 | return applyAnnotations(out, object.annotations); 102 | }, 103 | 104 | transform_all: (objects: Array): string => { 105 | return objects?.map((object) => ObjectTransformers[object.type](object as any)).join('') ?? ''; 106 | }, 107 | }; 108 | 109 | declare type BlockTransformerType = (block: GetBlockResponse, rule: TemplateRule) => string; 110 | 111 | /** 112 | * The block transformers. 113 | * 114 | * The keys are the block types and the value is the NotionBlock to 115 | * Markdown controller for the block. 116 | * 117 | * These can be overwritten to provide custom implementations. 118 | */ 119 | export const BlockTransformers: Record = { 120 | paragraph: (block: GetBlockResponse): string => { 121 | const blk = block as any; 122 | if (blk.type === 'paragraph') { 123 | return transformAllObjectsWithPrefix(blk.paragraph.rich_text, ''); 124 | } 125 | 126 | return ''; 127 | }, 128 | 129 | heading_1: (block: GetBlockResponse): string => { 130 | const blk = block as any; 131 | 132 | if (blk.type === 'heading_1') { 133 | return transformAllObjectsWithPrefix(blk.heading_1.rich_text, '# '); 134 | } 135 | 136 | return ''; 137 | }, 138 | 139 | heading_2: (block: GetBlockResponse): string => { 140 | const blk = block as any; 141 | if (blk.type === 'heading_2') { 142 | return transformAllObjectsWithPrefix(blk.heading_2.rich_text, '## '); 143 | } 144 | 145 | return ''; 146 | }, 147 | 148 | heading_3: (block: GetBlockResponse): string => { 149 | const blk = block as any; 150 | if (blk.type === 'heading_3') { 151 | return transformAllObjectsWithPrefix(blk.heading_3.rich_text, '### '); 152 | } 153 | 154 | return ''; 155 | }, 156 | 157 | bulleted_list_item: (block: GetBlockResponse): string => { 158 | const blk = block as any; 159 | if (blk.type === 'bulleted_list_item') { 160 | return transformAllObjectsWithPrefix(blk.bulleted_list_item.rich_text, '- '); 161 | } 162 | 163 | return ''; 164 | }, 165 | 166 | numbered_list_item: (block: GetBlockResponse, _): string => { 167 | const blk = block as any; 168 | if (blk.type === 'numbered_list_item') { 169 | return transformAllObjectsWithPrefix(blk.numbered_list_item.rich_text, `1. `); 170 | } 171 | 172 | return ''; 173 | }, 174 | 175 | to_do: (block: GetBlockResponse): string => { 176 | const blk = block as any; 177 | if (blk.type === 'to_do') { 178 | const check = blk.to_do.checked ? 'X' : ' '; 179 | return transformAllObjectsWithPrefix(blk.to_do.rich_text, `- [${check}] `); 180 | } 181 | 182 | return ''; 183 | }, 184 | 185 | toggle: (block: GetBlockResponse): string => { 186 | const blk = block as any; 187 | if (blk.type === 'toggle') { 188 | return transformAllObjectsWithPrefix(blk.toggle.rich_text, ''); 189 | } 190 | 191 | return ''; 192 | }, 193 | 194 | child_page: (block: GetBlockResponse): string => { 195 | const blk = block as any; 196 | if (blk.type === 'child_page') { 197 | const id = block.id.split('-').join(''); 198 | return `[${blk.child_page.title}](https://www.notion.so/${id})`; 199 | } 200 | 201 | return ''; 202 | }, 203 | 204 | child_database: (block: GetBlockResponse): string => { 205 | const blk = block as any; 206 | if (blk.type === 'child_database') { 207 | const id = block.id.split('-').join(''); 208 | return `[${blk.child_database.title}](https://www.notion.so/${id})`; 209 | } 210 | 211 | return ''; 212 | }, 213 | 214 | embed: (block: GetBlockResponse): string => { 215 | const blk = block as any; 216 | if (blk.type === 'embed') { 217 | const caption = transformAllObjectsWithPrefix(blk.embed.caption, ''); 218 | return `[${caption}](${blk.embed.url} ':include')`; 219 | } 220 | 221 | return ''; 222 | }, 223 | 224 | image: (block: GetBlockResponse, rule: TemplateRule): string => { 225 | const blk = block as any; 226 | if (blk.type === 'image') { 227 | const media = getMedia(blk.image, rule); 228 | return `![${media.captionMarkdown}](${media.src} "${media.captionMarkdown}")`; 229 | } 230 | 231 | return ''; 232 | }, 233 | 234 | video: (block: GetBlockResponse, rule: TemplateRule): string => { 235 | const blk = block as any; 236 | if (blk.type === 'video') { 237 | const media = getMedia(blk.video, rule); 238 | return `[${media.captionMarkdown}](${media.src} ':include')`; 239 | } 240 | 241 | return ''; 242 | }, 243 | 244 | file: (block: GetBlockResponse, rule: TemplateRule): string => { 245 | const blk = block as any; 246 | if (blk.type === 'file') { 247 | const media = getMedia(blk.file, rule); 248 | return `[${media.captionMarkdown}](${media.src} ':include')`; 249 | } 250 | 251 | return ''; 252 | }, 253 | 254 | pdf: (block: GetBlockResponse, rule: TemplateRule): string => { 255 | const blk = block as any; 256 | if (blk.type === 'pdf') { 257 | const media = getMedia(blk.pdf, rule); 258 | return `[${media.captionMarkdown}](${media.src} ':include')`; 259 | } 260 | 261 | return ''; 262 | }, 263 | 264 | audio: (block: GetBlockResponse, rule: TemplateRule): string => { 265 | const blk = block as any; 266 | if (blk.type === 'audio') { 267 | const media = getMedia(blk.audio, rule); 268 | return `[${media.captionMarkdown}](${media.src} ':include')`; 269 | } 270 | 271 | return ''; 272 | }, 273 | 274 | bookmark: (block: GetBlockResponse): string => { 275 | const blk = block as any; 276 | if (blk.type === 'bookmark') { 277 | const url = blk.bookmark.url; 278 | return `[${url}](${url})`; 279 | } 280 | 281 | return ''; 282 | }, 283 | 284 | callout: (block: GetBlockResponse): string => { 285 | const blk = block as any; 286 | if (blk.type === 'callout') { 287 | const text = transformAllObjectsWithPrefix(blk.callout.rich_text, ''); 288 | if (blk.callout.icon?.type === 'emoji') { 289 | const icon = blk.callout.icon.emoji; 290 | return `> ${icon} ${text}`; 291 | } 292 | 293 | return `> ${text}`; 294 | } 295 | 296 | return ''; 297 | }, 298 | 299 | quote: (block: GetBlockResponse): string => { 300 | const blk = block as any; 301 | if (blk.type === 'quote') { 302 | const text = transformAllObjectsWithPrefix(blk.quote.rich_text, ''); 303 | return `> ${text}`; 304 | } 305 | 306 | return ''; 307 | }, 308 | 309 | equation: (block: GetBlockResponse): string => { 310 | const blk = block as any; 311 | if (blk.type === 'equation') { 312 | const expression = blk.equation.expression; 313 | return `$$${expression}$$`; 314 | } 315 | 316 | return ''; 317 | }, 318 | 319 | divider: (block: GetBlockResponse): string => { 320 | return '---'; 321 | }, 322 | 323 | table_of_contents: (block: GetBlockResponse): string => { 324 | return ''; 325 | }, 326 | 327 | breadcrumb: (block: GetBlockResponse): string => { 328 | return ''; 329 | }, 330 | 331 | code: (block: GetBlockResponse): string => { 332 | const blk = block as any; 333 | if (blk.type === 'code') { 334 | const text = transformAllObjectsWithPrefix(blk.code.rich_text, ''); 335 | return '```' + blk.code.language + '\n' + text + '\n```'; 336 | } 337 | return ''; 338 | }, 339 | 340 | unsupported: (block: GetBlockResponse): string => { 341 | return 'Unsupported'; 342 | }, 343 | }; 344 | 345 | export default class MarkdownService { 346 | public genMarkdownForBlocks(blocks: NotionBlock[], rule: TemplateRule): string { 347 | return this.genMarkdownForBlocksWithIndent(blocks, rule, ''); 348 | } 349 | 350 | public genMarkdownForBlocksWithIndent(blocks: NotionBlock[], rule: TemplateRule, indent: string): string { 351 | let out = ''; 352 | 353 | for (const block of blocks) { 354 | const transformer = this.getTransformerForBlock(block); 355 | const markdown = this.getMarkdownForBlock(block, rule, transformer); 356 | 357 | if (out.length === 0) { 358 | out = `${indent}${markdown}\n`; 359 | } else { 360 | out = `${out}\n${indent}${markdown}\n`; 361 | } 362 | 363 | const childrenMarkdown = this.genMarkdownForBlocksWithIndent(block.children, rule, `${indent} `).trimEnd(); 364 | if (childrenMarkdown.length !== 0) { 365 | out = `${out}\n${childrenMarkdown}\n`; 366 | } 367 | } 368 | 369 | return out; 370 | } 371 | 372 | private getTransformerForBlock(block: NotionBlock): BlockTransformerType | undefined { 373 | for (const blockType of Object.keys(BlockTransformers)) { 374 | if (blockType === (block.data as any).type) { 375 | return BlockTransformers[blockType]; 376 | } 377 | } 378 | 379 | return undefined; 380 | } 381 | 382 | private getMarkdownForBlock( 383 | block: NotionBlock, 384 | rule: TemplateRule, 385 | transformer: BlockTransformerType | undefined, 386 | ): string { 387 | if (transformer) { 388 | return transformer(block.data, rule); 389 | } else { 390 | return `Unknown Block: ${(block.data as any).type}`; 391 | } 392 | } 393 | 394 | private static _instance: MarkdownService; 395 | public static get instance(): MarkdownService { 396 | return this._instance ?? (this._instance = new MarkdownService()); 397 | } 398 | 399 | public static setMockedInstance(instance: MarkdownService) { 400 | this._instance = instance; 401 | } 402 | } 403 | -------------------------------------------------------------------------------- /src/services/media_service.ts: -------------------------------------------------------------------------------- 1 | import * as pathUtils from 'path'; 2 | import * as urlUtils from 'url'; 3 | import request from 'request'; 4 | import * as fs from 'fs'; 5 | 6 | import { TemplateRule } from '../models/config_models'; 7 | import mediaTypes from '../constants/media_types'; 8 | import FileSystemService from './file_system_service'; 9 | import FileNameService from './file_name_service'; 10 | 11 | /** 12 | * A service for fetching media assets from given Notion URLs. 13 | * 14 | * Uses Collect-Commit pattern. 15 | */ 16 | export default class MediaService { 17 | private readonly requests = new Map(); 18 | 19 | public stageFetchRequest(url: string, templateRule: TemplateRule): string { 20 | const destinationController = new MediaDestinationController({ 21 | forRule: templateRule, 22 | }); 23 | 24 | const relativeDestination = destinationController.makeFileDestinationForAssetWithUrl(url); 25 | const absPath = MediaDestinationController.getAbsoluteDestinationPath( 26 | templateRule.outDir, 27 | templateRule.writeMediaTo, 28 | relativeDestination, 29 | ); 30 | 31 | this.requests.set(url, { 32 | relativePath: relativeDestination, 33 | absolutePath: absPath, 34 | }); 35 | 36 | return relativeDestination; 37 | } 38 | 39 | public async commit(): Promise { 40 | const promises = new Array>(); 41 | 42 | for (const [url, destinationPath] of this.requests) { 43 | promises.push( 44 | new Promise((resolve, reject) => { 45 | const destination = fs.createWriteStream(destinationPath.absolutePath); 46 | request(url).pipe(destination).on('error', reject).on('finish', resolve); 47 | }), 48 | ); 49 | } 50 | 51 | await Promise.all(promises); 52 | } 53 | 54 | private static _instance: MediaService; 55 | public static get instance(): MediaService { 56 | return this._instance ?? (this._instance = new MediaService()); 57 | } 58 | 59 | public static setMockedInstance(instance: MediaService) { 60 | this._instance = instance; 61 | } 62 | } 63 | 64 | export class MediaDestinationController { 65 | private templateRule: TemplateRule; 66 | 67 | constructor(args: { forRule: TemplateRule }) { 68 | this.templateRule = args.forRule; 69 | } 70 | 71 | public makeFileDestinationForAssetWithUrl(url: string): string { 72 | const subfolderName = this.getSubfolderNameForUrl(url); 73 | const filename = this.genUniqueBasename(url); 74 | const parentFolder = MediaDestinationController.getAbsoluteDestinationPath( 75 | this.templateRule.outDir, 76 | this.templateRule.writeMediaTo, 77 | subfolderName, 78 | ); 79 | 80 | if (!FileSystemService.instance.doesFolderExist(parentFolder)) 81 | FileSystemService.instance.createFolder(parentFolder); 82 | 83 | return `${subfolderName}/${filename}`; 84 | } 85 | 86 | public static getAbsoluteDestinationPath( 87 | outDir: string, 88 | writeMediaTo: string | undefined, 89 | subfolderName: string, 90 | ): string { 91 | return Boolean(writeMediaTo) ? `${writeMediaTo}/${subfolderName}` : `${outDir}/${subfolderName}`; 92 | } 93 | 94 | private getSubfolderNameForUrl(url: string): string { 95 | const fileExtension = pathUtils.extname(new urlUtils.URL(url).pathname); 96 | for (const [folderName, acceptedExtensions] of Object.entries(mediaTypes)) { 97 | const acceptsExtension = acceptedExtensions.indexOf(fileExtension) >= 0; 98 | if (acceptsExtension) return folderName; 99 | } 100 | 101 | return 'other_media'; 102 | } 103 | 104 | private genUniqueBasename(url: string) { 105 | const basename = this.getUrlBasename(url); 106 | const fileExtensionStartIndex = basename.lastIndexOf('.'); 107 | const uniqueName = FileNameService.instance.genUnique(); 108 | 109 | if (fileExtensionStartIndex < 0) { 110 | return uniqueName; 111 | } else { 112 | return `${uniqueName}${basename.substring(fileExtensionStartIndex)}`; 113 | } 114 | } 115 | 116 | private getUrlBasename(url: string): string { 117 | return pathUtils.basename(new urlUtils.URL(url).pathname); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/services/mustache_service.ts: -------------------------------------------------------------------------------- 1 | import { render } from 'mustache'; 2 | 3 | export default class MustacheService { 4 | public render(view: object, template: string): string { 5 | return render(template, view); 6 | } 7 | 8 | private static _instance: MustacheService; 9 | public static get instance(): MustacheService { 10 | return this._instance ?? (this._instance = new MustacheService()); 11 | } 12 | 13 | public static setMockedInstance(instance: MustacheService) { 14 | this._instance = instance; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/notion_service.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '@notionhq/client'; 2 | import { GetBlockResponse, GetDatabaseResponse, GetPageResponse } from '@notionhq/client/build/src/api-endpoints'; 3 | import { Token } from '../models/app_models'; 4 | import { SortsParams } from '../models/config_models'; 5 | import { APIErrorCode } from '@notionhq/client/build/src'; 6 | 7 | const ALL = Number.POSITIVE_INFINITY; 8 | 9 | export default class NotionService { 10 | public getDatabase(args: { withId: string; withToken: Token }): Promise { 11 | const notion = this.initNotionClient(args.withToken); 12 | return resultOf(() => 13 | notion.databases.retrieve({ 14 | database_id: args.withId, 15 | }), 16 | ); 17 | } 18 | 19 | public async *queryForDatabasePages(args: { 20 | databaseId: string; 21 | withToken: Token; 22 | takeOnly?: number; 23 | sort?: SortsParams; 24 | filter?: object; 25 | }): AsyncGenerator { 26 | // TODO: Unit test the logic here. 27 | const notion = this.initNotionClient(args.withToken); 28 | let pagesToReadCount = ALL; 29 | if (args.takeOnly !== undefined) pagesToReadCount = args.takeOnly; 30 | 31 | let readPages: GetPageResponse[] = []; 32 | 33 | function shouldReadMorePages() { 34 | return readPages.length < pagesToReadCount; 35 | } 36 | 37 | let hasMore = true; 38 | let nextCursor: string | undefined; 39 | 40 | while (hasMore && shouldReadMorePages()) { 41 | const response = await resultOf(() => 42 | notion.databases.query({ 43 | database_id: args.databaseId, 44 | page_size: Math.min(pagesToReadCount - readPages.length, 100), 45 | sorts: args.sort, 46 | filter: args.filter as any, 47 | 48 | start_cursor: nextCursor, 49 | }), 50 | ); 51 | 52 | readPages = readPages.concat(response.results); 53 | hasMore = response.has_more; 54 | nextCursor = response.next_cursor as any; 55 | } 56 | 57 | yield* readPages; 58 | } 59 | 60 | // TODO: Remove in favor of getBlockChildren 61 | public getPageBlocks(args: { pageId: string; withToken: Token }): AsyncGenerator { 62 | return this.getBlockChildren({ 63 | blockId: args.pageId, 64 | withToken: args.withToken, 65 | }); 66 | } 67 | 68 | public async *getBlockChildren(args: { 69 | blockId: string; 70 | withToken: Token; 71 | }): AsyncGenerator { 72 | // TODO: Unit test the logic here 73 | const notion = this.initNotionClient(args.withToken); 74 | let hasMore = true; 75 | let nextCursor: string | undefined; 76 | 77 | while (hasMore) { 78 | const response = await resultOf(() => 79 | notion.blocks.children.list({ 80 | block_id: args.blockId, 81 | start_cursor: nextCursor, 82 | }), 83 | ); 84 | 85 | yield* response.results; 86 | hasMore = response.has_more; 87 | nextCursor = response.next_cursor as any; 88 | } 89 | } 90 | 91 | private initNotionClient(notionToken: Token): Client { 92 | return new Client({ 93 | auth: notionToken.token, 94 | }); 95 | } 96 | 97 | private static _instance: NotionService; 98 | public static get instance(): NotionService { 99 | return this._instance ?? (this._instance = new NotionService()); 100 | } 101 | 102 | public static setMockedInstance(instance: NotionService) { 103 | this._instance = instance; 104 | } 105 | } 106 | 107 | 108 | function sleep(ms: number) { 109 | return new Promise((resolve) => setTimeout(resolve, ms)); 110 | } 111 | 112 | const allowedNotionApiCallsPerSecond = 3; 113 | const sleepTimeInMillis = 1000/allowedNotionApiCallsPerSecond; 114 | 115 | export async function resultOf(notionCall: () => Promise): Promise { 116 | try { 117 | return await notionCall(); 118 | } catch (e) { 119 | if ((e as any).code === APIErrorCode.RateLimited) { 120 | await sleep(sleepTimeInMillis); 121 | return resultOf(notionCall); 122 | } 123 | 124 | throw e; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/services/post_processing_service.ts: -------------------------------------------------------------------------------- 1 | import FileSystemService from './file_system_service'; 2 | import getRelativePath from 'get-relative-path'; 3 | import { getLogger } from '../logger'; 4 | 5 | /** 6 | * Buffers a file write and only writes it to the file system once all the links 7 | * it has to other pages have been resolved. 8 | * 9 | * Notion allows users to mention other pages in their pages. When the 10 | * Markdown for a page with such links is generated, we want to maintain these 11 | * links. However, by the time the page is generated, some of the pages 12 | * it mentions might not be generated yet, which means we will not know 13 | * their paths in the output directory. To fix this we allow the markdown 14 | * transfomers, and config files, to use path placeholders when linking to a 15 | * a page that is yet to be generated. 16 | * 17 | * A path placeholder has the form `:::pathTo:::page_id:::`, where page_id is the id 18 | * of the page being linked to. When the text content of a page is submitted, the 19 | * `PostProcessingService` looks for all the other buffered pages that link 20 | * to the page and overwrites the path placelders with the actual path to the page, 21 | * such that a page is only flushed to the file system once all the path placeholders 22 | * it has have been resolved. 23 | * 24 | * The {@link flush} method can be used to force all buffered pages to be written to the 25 | * file system. This should be called when there are no more pages expected to be 26 | * generated. 27 | * 28 | * Using post processing to solve the page linking problem only works if the pages 29 | * being linked to are going to be generated. If not, the links will be broken. 30 | */ 31 | export default class PostProcessingService { 32 | private readonly pagePaths: { pageId: string; path: string }[] = []; 33 | private readonly pagesWithLinks: { path: string; data: string }[] = []; 34 | 35 | public submit(content: string, pgPath: string, pgId: string): void { 36 | this.pagePaths.push({ 37 | pageId: pgId, 38 | path: pgPath, 39 | }); 40 | 41 | if (content.indexOf(':::pathTo:::') > 0) { 42 | this.pagesWithLinks.push({ 43 | path: pgPath, 44 | data: content, 45 | }); 46 | } else { 47 | FileSystemService.instance.writeStringToFile(content, pgPath); 48 | getLogger().logPageFlushed(pgPath); 49 | } 50 | } 51 | 52 | public flush(): void { 53 | for (const pagePath of this.pagePaths) { 54 | for (const pageWithLink of this.pagesWithLinks) { 55 | const link = encodeURI(this.getRelativeLink(pageWithLink.path, pagePath.path)); 56 | pageWithLink.data = pageWithLink.data.replace(`:::pathTo:::${pagePath.pageId}:::`, link); 57 | } 58 | } 59 | 60 | for (const page of this.pagesWithLinks) { 61 | FileSystemService.instance.writeStringToFile(page.data, page.path); 62 | getLogger().logPageFlushed(page.path); 63 | } 64 | } 65 | 66 | private getRelativeLink(from: string, to: string): string { 67 | if (to.indexOf('/') < 0 || to.startsWith('./')) { 68 | // Path is already relative 69 | return to; 70 | } 71 | 72 | return getRelativePath(from, to); 73 | } 74 | 75 | private static _instance: PostProcessingService; 76 | public static get instance(): PostProcessingService { 77 | return this._instance ?? (this._instance = new PostProcessingService()); 78 | } 79 | 80 | public static setMockedInstance(instance: PostProcessingService) { 81 | this._instance = instance; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /test/build_service.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, setup } from 'mocha'; 2 | import { expect } from 'chai'; 3 | import { Context, DatabaseRule, NotionBlock, NotionPage } from '../src/models/config_models'; 4 | import { NotionDatabaseAssociation, Token } from '../src/models/app_models'; 5 | import { 6 | BlockChildrenFetcher, 7 | CachingFileReader, 8 | DatabaseAssociationFinder, 9 | DatabaseFetcher, 10 | FileExtensionFinder, 11 | SecondaryDatabasesFetcher, 12 | TemplateRuleOutputWriter, 13 | } from '../src/services/build_service'; 14 | import { 15 | asMockedBlockChildrenFetcher, 16 | asMockedCachingFileReader, 17 | asMockedDatabaseAssociationFinder, 18 | asMockedDatabaseFetcher, 19 | asMockedFileExtensionFinder, 20 | setMockedFileSystemService, 21 | setMockedMustacheService, 22 | setMockedNotionService, 23 | setMockedPostProcessingService, 24 | } from './mocking_utils'; 25 | import { GetBlockResponse } from '@notionhq/client/build/src/api-endpoints'; 26 | 27 | describe('BuildService tests', () => {}); 28 | 29 | describe('FileExtensionFinder tests', () => { 30 | describe('findFileExtensionOf(path)', () => { 31 | it("Should return '.md' when given 'abc.def.md'", () => { 32 | expect(new FileExtensionFinder().findFileExtensionOf('abc.def.md')).to.equal('.md'); 33 | }); 34 | 35 | it("Should return empty string when given 'abc'", () => { 36 | return expect(new FileExtensionFinder().findFileExtensionOf('abc')).to.be.empty; 37 | }); 38 | }); 39 | }); 40 | 41 | describe('DatabaseAssociationFinder tests', () => { 42 | describe('findDatabaseAssociationFor(key, databaseAssociations)', () => { 43 | it('Should return the first found database association for the rule when it exists', () => { 44 | const rule: DatabaseRule = { 45 | database: 'abc', 46 | }; 47 | 48 | const associations: NotionDatabaseAssociation[] = [ 49 | { 50 | name: 'abc', 51 | notionDatabaseId: 'aaa', 52 | notionIntegrationToken: { 53 | token: 'ccc', 54 | }, 55 | }, 56 | ]; 57 | 58 | expect(new DatabaseAssociationFinder().findDatabaseAssociationFor(rule, associations)).to.deep.equal({ 59 | name: 'abc', 60 | notionDatabaseId: 'aaa', 61 | notionIntegrationToken: { 62 | token: 'ccc', 63 | }, 64 | }); 65 | }); 66 | 67 | it("Should throw Error('The database association \"abc\" does not exist.') when the rule has 'abc' as its database but the database is not in the associations", () => { 68 | const rule: DatabaseRule = { 69 | database: 'abc', 70 | }; 71 | 72 | let thrownError = new Error(); 73 | try { 74 | new DatabaseAssociationFinder().findDatabaseAssociationFor(rule, []); 75 | } catch (e) { 76 | thrownError = e as any; 77 | } 78 | 79 | expect(thrownError.message).to.equal('The database association "abc" does not exist.'); 80 | }); 81 | }); 82 | }); 83 | 84 | describe('CachingFileReader', () => { 85 | describe('readAsString(path)', () => { 86 | it('Should return the same value returned by FileSystemService.readFileAsString on first call', () => { 87 | setMockedFileSystemService({ 88 | readFileAsString: (_) => 'abc', 89 | }); 90 | 91 | const instance = new CachingFileReader(); 92 | expect(instance.readAsString('path')).to.equal('abc'); 93 | }); 94 | 95 | it('Should not call FileSystemService.readFileAsString on subsequent reads for the file, but return the cached contents instead', () => { 96 | let count = 0; 97 | setMockedFileSystemService({ 98 | readFileAsString: (_) => { 99 | count++; 100 | return `${count}`; 101 | }, 102 | }); 103 | 104 | const instance = new CachingFileReader(); 105 | 106 | // Call it a couple of times with the same path 107 | instance.readAsString('path'); 108 | instance.readAsString('path'); 109 | 110 | expect(instance.readAsString('path')).to.equal('1'); 111 | }); 112 | }); 113 | }); 114 | 115 | describe('TemplateRuleOutputWriter tests', () => { 116 | describe('write(page, pageTemplateRule)', () => { 117 | let instance: TemplateRuleOutputWriter; 118 | setup(() => { 119 | instance = new TemplateRuleOutputWriter(); 120 | instance.setFileExtensionFinder( 121 | asMockedFileExtensionFinder({ 122 | findFileExtensionOf: (_) => '.html', 123 | }), 124 | ); 125 | }); 126 | 127 | describe('Rendering', () => { 128 | setup(() => { 129 | instance.setCachingFileReader( 130 | asMockedCachingFileReader({ 131 | readAsString: (path) => `contents of ${path}`, 132 | }), 133 | ); 134 | 135 | setMockedFileSystemService({ 136 | doesFolderExist: (_) => true, 137 | }); 138 | 139 | setMockedPostProcessingService({ 140 | submit: (_, __, ___) => undefined, 141 | }); 142 | }); 143 | 144 | it('Should pass the whole page object as the view to the MustacheService', async () => { 145 | let passedPage: any; 146 | setMockedMustacheService({ 147 | render: (view, _) => { 148 | passedPage = view; 149 | return ''; 150 | }, 151 | }); 152 | 153 | await instance.write( 154 | { 155 | abc: 'abc', 156 | data: { 157 | id: 'abc', 158 | }, 159 | } as any, 160 | { 161 | template: '', 162 | outDir: '', 163 | uses: {} as any, 164 | alsoUses: [], 165 | }, 166 | ); 167 | 168 | expect(passedPage).to.deep.equal({ 169 | abc: 'abc', 170 | data: { 171 | id: 'abc', 172 | }, 173 | }); 174 | }); 175 | 176 | it('Should pass the contents of the file in pageTemplateRule.template as the template to the MustacheService', async () => { 177 | let passedTemplate: any; 178 | setMockedMustacheService({ 179 | render: (_, template) => { 180 | passedTemplate = template; 181 | return ''; 182 | }, 183 | }); 184 | 185 | await instance.write( 186 | { 187 | data: { 188 | id: 'abc', 189 | }, 190 | } as any, 191 | { 192 | template: 'public/test.html', 193 | outDir: '', 194 | uses: {} as any, 195 | alsoUses: [], 196 | }, 197 | ); 198 | 199 | expect(passedTemplate).to.deep.equal('contents of public/test.html'); 200 | }); 201 | }); 202 | 203 | describe('Output file writing', () => { 204 | setup(() => { 205 | setMockedMustacheService({ 206 | render: (_, __) => { 207 | return 'populated contents'; 208 | }, 209 | }); 210 | 211 | instance.setCachingFileReader( 212 | asMockedCachingFileReader({ 213 | readAsString: (_) => '', 214 | }), 215 | ); 216 | }); 217 | 218 | describe('When pageTemplateRule.outDir already exists', () => { 219 | it('Should write the output file to the already existing folder', async () => { 220 | let writtenData: any; 221 | let writtenToPath: any; 222 | 223 | setMockedFileSystemService({ 224 | doesFolderExist: (_) => true, 225 | }); 226 | 227 | setMockedPostProcessingService({ 228 | submit: (data, path, ___) => { 229 | writtenData = data; 230 | writtenToPath = path; 231 | }, 232 | }); 233 | 234 | await instance.write( 235 | { 236 | _title: 'index', 237 | data: { 238 | id: 'abc', 239 | }, 240 | } as any, 241 | { 242 | template: 'public/test.html', 243 | outDir: 'out', 244 | uses: {} as any, 245 | alsoUses: [], 246 | }, 247 | ); 248 | 249 | expect(writtenData).to.equal('populated contents'); 250 | expect(writtenToPath).to.equal('out/index.html'); 251 | }); 252 | }); 253 | 254 | describe('When pageTemplateRule.outDir does not exist', () => { 255 | it('Should create the folder', async () => { 256 | let createdFolder: any; 257 | 258 | setMockedFileSystemService({ 259 | doesFolderExist: (_) => false, 260 | createFolder: (folder) => { 261 | createdFolder = folder; 262 | }, 263 | }); 264 | 265 | setMockedPostProcessingService({ 266 | submit: (_, __, ___) => undefined, 267 | }); 268 | 269 | await instance.write( 270 | { 271 | _title: 'index', 272 | data: { 273 | id: 'abc', 274 | }, 275 | } as any, 276 | { 277 | template: 'public/test.html', 278 | outDir: 'out', 279 | uses: {} as any, 280 | alsoUses: [], 281 | }, 282 | ); 283 | 284 | expect(createdFolder).to.equal('out'); 285 | }); 286 | 287 | it('Should write the output file to the created folder', async () => { 288 | let writtenData: any; 289 | let writtenToPath: any; 290 | 291 | setMockedFileSystemService({ 292 | doesFolderExist: (_) => false, 293 | createFolder: (_) => undefined, 294 | }); 295 | 296 | setMockedPostProcessingService({ 297 | submit: (data, path, ___) => { 298 | writtenData = data; 299 | writtenToPath = path; 300 | }, 301 | }); 302 | 303 | await instance.write( 304 | { 305 | _title: 'index', 306 | data: { 307 | id: 'abc', 308 | }, 309 | } as any, 310 | { 311 | template: 'public/test.html', 312 | outDir: 'out', 313 | uses: {} as any, 314 | alsoUses: [], 315 | }, 316 | ); 317 | 318 | expect(writtenData).to.equal('populated contents'); 319 | expect(writtenToPath).to.equal('out/index.html'); 320 | }); 321 | }); 322 | }); 323 | }); 324 | }); 325 | 326 | describe('BlockChildrenFetcher tests', () => { 327 | describe('fetchChildren(blockId, notionToken)', () => { 328 | it('Should return the children for the block as well as their children when they do have children', async () => { 329 | const blockIdToChildren = { 330 | 'parent-1': [ 331 | { 332 | id: 'child-1', 333 | has_children: false, 334 | }, 335 | { 336 | id: 'child-2', 337 | has_children: true, 338 | }, 339 | ], 340 | 'child-2': [ 341 | { 342 | id: 'grandchild-1', 343 | has_children: false, 344 | }, 345 | ], 346 | }; 347 | 348 | setMockedNotionService({ 349 | getBlockChildren: (args) => { 350 | async function* ret() { 351 | yield* (blockIdToChildren as any)[args.blockId] as GetBlockResponse[]; 352 | } 353 | 354 | return ret(); 355 | }, 356 | }); 357 | 358 | const instance = new BlockChildrenFetcher(); 359 | expect(await instance.fetchChildren('parent-1', {} as any)).to.deep.equal([ 360 | { 361 | data: { 362 | id: 'child-1', 363 | has_children: false, 364 | }, 365 | children: [], 366 | }, 367 | 368 | { 369 | data: { 370 | id: 'child-2', 371 | has_children: true, 372 | }, 373 | children: [ 374 | { 375 | data: { 376 | id: 'grandchild-1', 377 | has_children: false, 378 | }, 379 | children: [], 380 | }, 381 | ], 382 | }, 383 | ]); 384 | }); 385 | }); 386 | }); 387 | 388 | describe('DatabaseFetcher tests', () => { 389 | const testContext: Context = { 390 | genMarkdownForBlocks: (_: NotionBlock[]) => '', 391 | others: {}, 392 | fetchMedia: (_) => { 393 | return {} as any; 394 | }, 395 | }; 396 | 397 | let instance: DatabaseFetcher; 398 | const fetchDb1Args = { 399 | databaseRule: { 400 | database: 'abc', 401 | fetchBlocks: true, 402 | takeOnly: 5, 403 | sort: { sort: 'xxx' } as any, 404 | filter: { filter: 'yyy' }, 405 | }, 406 | association: { 407 | name: 'abc', 408 | notionDatabaseId: 'db-1', 409 | notionIntegrationToken: {} as Token, 410 | }, 411 | context: testContext, 412 | onPostPageMapping: (page: NotionPage) => new Promise((resolve, _) => resolve(page)), 413 | }; 414 | 415 | const fetchDb2Args = { 416 | databaseRule: { 417 | database: 'efg', 418 | fetchBlocks: true, 419 | }, 420 | association: { 421 | name: 'efg', 422 | notionDatabaseId: 'db-2', 423 | notionIntegrationToken: {} as Token, 424 | }, 425 | context: testContext, 426 | onPostPageMapping: (page: NotionPage) => new Promise((resolve, _) => resolve(page)), 427 | }; 428 | 429 | setup(() => { 430 | instance = new DatabaseFetcher(); 431 | 432 | const databases = { 433 | 'db-1': { abc: 'abc', id: 'efg' }, 434 | 'db-2': { abc: 'abc', id: 'hij' }, 435 | }; 436 | 437 | const databasePages = { 438 | 'db-1': [], 439 | 'db-2': [ 440 | { 441 | id: 'page-1', 442 | }, 443 | { 444 | id: 'page-2', 445 | }, 446 | ], 447 | }; 448 | 449 | const Db2Page1Blocks: NotionBlock[] = []; 450 | const Db2Page2Blocks: NotionBlock[] = [ 451 | { 452 | data: { 453 | id: '', 454 | has_children: false, 455 | } as any, 456 | children: [], 457 | }, 458 | ]; 459 | 460 | const pageBlocks = { 461 | 'page-1': Db2Page1Blocks, 462 | 'page-2': Db2Page2Blocks, 463 | }; 464 | 465 | instance.setBlockChildrenFether( 466 | asMockedBlockChildrenFetcher({ 467 | fetchChildren: (blockId, _) => new Promise((resolve, _) => resolve((pageBlocks as any)[blockId])), 468 | }), 469 | ); 470 | 471 | setMockedNotionService({ 472 | getDatabase: (args) => { 473 | return new Promise((resolve, _) => { 474 | resolve((databases as any)[args.withId]); 475 | }); 476 | }, 477 | queryForDatabasePages: (args) => { 478 | async function* ret() { 479 | yield* (databasePages as any)[args.databaseId]; 480 | } 481 | 482 | return ret(); 483 | }, 484 | }); 485 | }); 486 | 487 | describe('fetchDatabase(args)', () => { 488 | it('Should return a database with data equal to that returned by NotionService.instance.getDatabase', async () => { 489 | const returnedDb = await instance.fetchDatabase(fetchDb1Args); 490 | 491 | expect(returnedDb).to.deep.equal({ 492 | pages: [], 493 | data: { abc: 'abc', id: 'efg' }, 494 | }); 495 | }); 496 | 497 | describe('takeOnly, sort, and filter arguments', () => { 498 | let usedTakeOnly: any; 499 | let usedSort: any; 500 | let usedFilter: any; 501 | 502 | async function capturePassedArguments() { 503 | setMockedNotionService({ 504 | getDatabase: (_) => { 505 | return new Promise((resolve, _) => { 506 | resolve({ id: '' } as any); 507 | }); 508 | }, 509 | queryForDatabasePages: (args) => { 510 | usedTakeOnly = args.takeOnly; 511 | usedSort = args.sort; 512 | usedFilter = args.filter; 513 | 514 | async function* ret() { 515 | yield* []; 516 | } 517 | 518 | return ret(); 519 | }, 520 | }); 521 | 522 | await instance.fetchDatabase(fetchDb1Args); 523 | } 524 | 525 | it('Should call NotionService.instance.queryForDatabasePages with the given databaseRule.takeOnly', async () => { 526 | await capturePassedArguments(); 527 | expect(usedTakeOnly).to.equal(5); 528 | }); 529 | 530 | it('Should call NotionService.instance.queryForDatabasePages with the given databaseRule.sort', async () => { 531 | await capturePassedArguments(); 532 | expect(usedSort).to.deep.equal({ sort: 'xxx' }); 533 | }); 534 | 535 | it('Should call NotionService.instance.queryForDatabasePages with the given databaseRule.filter', async () => { 536 | await capturePassedArguments(); 537 | expect(usedFilter).to.deep.equal({ filter: 'yyy' }); 538 | }); 539 | }); 540 | 541 | describe('For each page yielded by NotionService.instance.queryForDatabasePages', () => { 542 | async function testDoesNotFetchBlocks(fetchBlocksValue: boolean | undefined) { 543 | const fetchArgs = { 544 | databaseRule: { 545 | database: 'efg', 546 | fetchBlocks: fetchBlocksValue, 547 | }, 548 | association: { 549 | name: 'efg', 550 | notionDatabaseId: 'db-2', 551 | notionIntegrationToken: {} as Token, 552 | }, 553 | context: testContext, 554 | onPostPageMapping: (page: NotionPage) => new Promise((resolve, _) => resolve(page)), 555 | }; 556 | 557 | const database = await instance.fetchDatabase(fetchArgs); 558 | expect(database.pages).to.deep.equal([ 559 | { 560 | _title: 'page-1', 561 | otherData: {}, 562 | data: { 563 | id: 'page-1', 564 | }, 565 | blocks: [], 566 | }, 567 | { 568 | _title: 'page-2', 569 | otherData: {}, 570 | data: { 571 | id: 'page-2', 572 | }, 573 | blocks: [], 574 | }, 575 | ]); 576 | } 577 | 578 | describe('When the databaseRule.fetchBlocks is undefined', () => { 579 | it('Should NOT fetch page blocks', () => { 580 | return testDoesNotFetchBlocks(undefined); 581 | }); 582 | }); 583 | 584 | describe('When the databaseRule.fetchBlocks is false', () => { 585 | it('Should NOT fetch page blocks', () => { 586 | return testDoesNotFetchBlocks(false); 587 | }); 588 | }); 589 | 590 | describe('When the databaseRule.fetchBlocks is true', () => { 591 | it('Should fetch page blocks', async () => { 592 | const fetchArgs = { 593 | databaseRule: { 594 | database: 'efg', 595 | fetchBlocks: true, 596 | }, 597 | association: { 598 | name: 'efg', 599 | notionDatabaseId: 'db-2', 600 | notionIntegrationToken: {} as Token, 601 | }, 602 | context: testContext, 603 | onPostPageMapping: (page: NotionPage) => new Promise((resolve, _) => resolve(page)), 604 | }; 605 | 606 | const database = await instance.fetchDatabase(fetchArgs); 607 | expect(database.pages).to.deep.equal([ 608 | { 609 | _title: 'page-1', 610 | otherData: {}, 611 | data: { 612 | id: 'page-1', 613 | }, 614 | blocks: [], 615 | }, 616 | { 617 | _title: 'page-2', 618 | otherData: {}, 619 | data: { 620 | id: 'page-2', 621 | }, 622 | blocks: [ 623 | { 624 | data: { 625 | id: '', 626 | has_children: false, 627 | }, 628 | children: [], 629 | }, 630 | ], 631 | }, 632 | ]); 633 | }); 634 | }); 635 | 636 | describe('When the given databaseRule does have a databaseRule.map closure', () => { 637 | it('Should pass the page object to the databaseRule.map closure with its blocks already set and page._title equal to page id', async () => { 638 | const mappedPages: NotionPage[] = []; 639 | 640 | const fetchArgs = { 641 | databaseRule: { 642 | database: 'efg', 643 | fetchBlocks: true, 644 | map: (page: NotionPage, _: Context) => { 645 | mappedPages.push(page); 646 | return page; 647 | }, 648 | }, 649 | association: { 650 | name: 'efg', 651 | notionDatabaseId: 'db-2', 652 | notionIntegrationToken: {} as Token, 653 | }, 654 | context: testContext, 655 | onPostPageMapping: (page: NotionPage) => new Promise((resolve, _) => resolve(page)), 656 | }; 657 | 658 | await instance.fetchDatabase(fetchArgs); 659 | expect(mappedPages).to.deep.equal([ 660 | { 661 | _title: 'page-1', 662 | otherData: {}, 663 | data: { 664 | id: 'page-1', 665 | }, 666 | blocks: [], 667 | }, 668 | { 669 | _title: 'page-2', 670 | otherData: {}, 671 | data: { 672 | id: 'page-2', 673 | }, 674 | blocks: [ 675 | { 676 | data: { 677 | id: '', 678 | has_children: false, 679 | }, 680 | children: [], 681 | }, 682 | ], 683 | }, 684 | ]); 685 | }); 686 | 687 | it('Should set the page._title to the page id when the databaseRule.map closure does not set it', async () => { 688 | const database = await instance.fetchDatabase(fetchDb2Args); 689 | expect(database.pages[0]._title).to.equal('page-1'); 690 | expect(database.pages[1]._title).to.equal('page-2'); 691 | }); 692 | 693 | it('Should set the page to that returned by the databaseRule.map closure', async () => { 694 | const fetchArgs = { 695 | databaseRule: { 696 | database: 'efg', 697 | map: (page: NotionPage, _: Context) => { 698 | const testObject = { 699 | abc: page.data.id, 700 | }; 701 | return testObject as any; 702 | }, 703 | }, 704 | association: { 705 | name: 'efg', 706 | notionDatabaseId: 'db-2', 707 | notionIntegrationToken: {} as Token, 708 | }, 709 | context: testContext, 710 | onPostPageMapping: (page: NotionPage) => new Promise((resolve, _) => resolve(page)), 711 | }; 712 | 713 | const database = await instance.fetchDatabase(fetchArgs); 714 | expect(database.pages[0]).to.deep.equal({ abc: 'page-1' }); 715 | expect(database.pages[1]).to.deep.equal({ abc: 'page-2' }); 716 | }); 717 | 718 | it('Should call the passed onPostPageMapping with the page returned by the databaseRule.map closure', async () => { 719 | const postMappedPages: NotionPage[] = []; 720 | 721 | const fetchArgs = { 722 | databaseRule: { 723 | database: 'efg', 724 | map: (page: NotionPage, _: Context) => { 725 | const testObject = { 726 | abc: page.data.id, 727 | }; 728 | return testObject as any; 729 | }, 730 | }, 731 | association: { 732 | name: 'efg', 733 | notionDatabaseId: 'db-2', 734 | notionIntegrationToken: {} as Token, 735 | }, 736 | context: testContext, 737 | onPostPageMapping: (page: NotionPage) => { 738 | postMappedPages.push(page); 739 | return new Promise((resolve, _) => resolve(page)); 740 | }, 741 | }; 742 | 743 | await instance.fetchDatabase(fetchArgs); 744 | expect(postMappedPages).to.deep.equal([{ abc: 'page-1' }, { abc: 'page-2' }]); 745 | }); 746 | }); 747 | 748 | describe('When the given databaseRule does NOT have a databaseRule.map closure', () => { 749 | it('Should set the page._title field of a page to its id', async () => { 750 | const database = await instance.fetchDatabase(fetchDb2Args); 751 | expect(database.pages[0]._title).to.equal('page-1'); 752 | expect(database.pages[1]._title).to.equal('page-2'); 753 | }); 754 | 755 | it('Should call the passed onPostPageMapping closure with the page object', async () => { 756 | const postMappedPages: NotionPage[] = []; 757 | 758 | const fetchArgs = { 759 | databaseRule: { 760 | database: 'efg', 761 | fetchBlocks: true, 762 | }, 763 | association: { 764 | name: 'efg', 765 | notionDatabaseId: 'db-2', 766 | notionIntegrationToken: {} as Token, 767 | }, 768 | context: testContext, 769 | onPostPageMapping: (page: NotionPage) => { 770 | postMappedPages.push(page); 771 | return new Promise((resolve, _) => resolve(page)); 772 | }, 773 | }; 774 | 775 | await instance.fetchDatabase(fetchArgs); 776 | expect(postMappedPages).to.deep.equal([ 777 | { 778 | _title: 'page-1', 779 | otherData: {}, 780 | data: { 781 | id: 'page-1', 782 | }, 783 | blocks: [], 784 | }, 785 | { 786 | _title: 'page-2', 787 | otherData: {}, 788 | data: { 789 | id: 'page-2', 790 | }, 791 | blocks: [ 792 | { 793 | data: { 794 | id: '', 795 | has_children: false, 796 | }, 797 | children: [], 798 | }, 799 | ], 800 | }, 801 | ]); 802 | }); 803 | }); 804 | }); 805 | }); 806 | }); 807 | 808 | describe('SecondaryDatabasesFetcher tests', () => { 809 | describe('fetchAll(databaseRules, databaseAssociations, ctx)', () => { 810 | let instance: SecondaryDatabasesFetcher; 811 | setup(() => { 812 | instance = new SecondaryDatabasesFetcher(); 813 | 814 | const notionDatabases = { 815 | 'db-1_notion': { abc: 'db-1' }, 816 | 'db-2_notion': { abc: 'db-2' }, 817 | }; 818 | 819 | instance.setDatabaseFetcher( 820 | asMockedDatabaseFetcher({ 821 | fetchDatabase: (args) => { 822 | const ret = (notionDatabases as any)[args.association.notionDatabaseId]; 823 | return new Promise((resolve, _) => resolve(ret)); 824 | }, 825 | }), 826 | ); 827 | 828 | instance.setDatabaseAssociationFinder( 829 | asMockedDatabaseAssociationFinder({ 830 | findDatabaseAssociationFor: (rule, _) => { 831 | return { 832 | name: rule.database, 833 | notionDatabaseId: `${rule.database}_notion`, 834 | notionIntegrationToken: {} as any, 835 | }; 836 | }, 837 | }), 838 | ); 839 | }); 840 | it('Should have all the secondary databases for all the given rules in the returned object', async () => { 841 | const databaseRules: DatabaseRule[] = [{ database: 'db-1' }, { database: 'db-2' }]; 842 | const secondaryDatabases = await instance.fetchAll(databaseRules, [], {} as any); 843 | 844 | expect(secondaryDatabases).to.deep.equal({ 845 | 'db-1': { abc: 'db-1' }, 846 | 'db-2': { abc: 'db-2' }, 847 | }); 848 | }); 849 | }); 850 | }); 851 | -------------------------------------------------------------------------------- /test/cli_utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { expect } from 'chai'; 3 | import { Association } from '../src/models/app_models'; 4 | import { compileAssociations } from '../src/cli_utils'; 5 | 6 | describe('cli_utils tests', () => { 7 | describe('compileAssociation', () => { 8 | it('Should return an empty list when given a string with whitespaces only', () => { 9 | return expect(compileAssociations(' \n \t')).to.be.empty; 10 | }); 11 | 12 | it("Should return [abc -> bcd, aaa -> bbb] object when given 'abc=bcd aaa=bbb'", () => { 13 | expect(compileAssociations('abc=bcd aaa=bbb')).to.deep.equal([ 14 | { 15 | key: 'abc', 16 | value: 'bcd', 17 | }, 18 | { 19 | key: 'aaa', 20 | value: 'bbb', 21 | }, 22 | ] as Association[]); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /test/markdown_service.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, setup } from 'mocha'; 2 | import { expect } from 'chai'; 3 | import MarkdownService, { BlockTransformers, getMedia, ObjectTransformers } from '../src/services/markdown_service'; 4 | import { 5 | DatabaseMentionObject, 6 | DateObject, 7 | EquationObject, 8 | FileObject, 9 | MentionObject, 10 | PageMentionObject, 11 | TextObject, 12 | TextRequest, 13 | UserObject, 14 | } from '../src/models/notion_objects'; 15 | import { GetBlockResponse } from '@notionhq/client/build/src/api-endpoints'; 16 | import { setMockedMediaService } from './mocking_utils'; 17 | import { NotionBlock } from '../src/models/config_models'; 18 | 19 | describe('ObjectTransformers tests', () => { 20 | describe('text', () => { 21 | describe("When content is 'abc' and all annotations are off", () => { 22 | function makeTextObjectWithLink( 23 | link: { 24 | url: TextRequest; 25 | } | null, 26 | ): TextObject { 27 | return { 28 | type: 'text', 29 | text: { 30 | content: 'abc', 31 | link: link, 32 | }, 33 | annotations: { 34 | bold: false, 35 | italic: false, 36 | strikethrough: false, 37 | underline: false, 38 | code: false, 39 | color: 'default', 40 | }, 41 | plain_text: 'abc', 42 | href: link?.url ?? null, 43 | }; 44 | } 45 | 46 | it("Should return 'abc' when link is null", () => { 47 | const object = makeTextObjectWithLink(null); 48 | expect(ObjectTransformers.text(object)).to.equal('abc'); 49 | }); 50 | 51 | it("Should return '[abc](http://example.com)' link.url is 'http://example.com'", () => { 52 | const object = makeTextObjectWithLink({ 53 | url: 'http://example.com', 54 | }); 55 | expect(ObjectTransformers.text(object)).to.equal('[abc](http://example.com)'); 56 | }); 57 | }); 58 | 59 | describe("When content is 'abc' and link is null", () => { 60 | function makeTextObjectWithAnnotations(annotations: { 61 | bold: boolean; 62 | italic: boolean; 63 | strikethrough: boolean; 64 | underline: boolean; 65 | code: boolean; 66 | color: 'default'; 67 | }): TextObject { 68 | return { 69 | type: 'text', 70 | text: { 71 | content: 'abc', 72 | link: null, 73 | }, 74 | annotations: annotations, 75 | plain_text: 'abc', 76 | href: null, 77 | }; 78 | } 79 | 80 | it("Should return '**abc**' when only the bold annotation is true", () => { 81 | const object = makeTextObjectWithAnnotations({ 82 | bold: true, 83 | italic: false, 84 | strikethrough: false, 85 | underline: false, 86 | code: false, 87 | color: 'default', 88 | }); 89 | 90 | expect(ObjectTransformers.text(object)).to.equal('**abc**'); 91 | }); 92 | 93 | it("Should return '*abc*' when only the italic annotation is true", () => { 94 | const object = makeTextObjectWithAnnotations({ 95 | bold: false, 96 | italic: true, 97 | strikethrough: false, 98 | underline: false, 99 | code: false, 100 | color: 'default', 101 | }); 102 | 103 | expect(ObjectTransformers.text(object)).to.equal('*abc*'); 104 | }); 105 | 106 | it("Should return '~~abc~~' when only the strikethrough annotation is true", () => { 107 | const object = makeTextObjectWithAnnotations({ 108 | bold: false, 109 | italic: false, 110 | strikethrough: true, 111 | underline: false, 112 | code: false, 113 | color: 'default', 114 | }); 115 | 116 | expect(ObjectTransformers.text(object)).to.equal('~~abc~~'); 117 | }); 118 | 119 | it("Should return '`abc`' when only the code annotation is true", () => { 120 | const object = makeTextObjectWithAnnotations({ 121 | bold: false, 122 | italic: false, 123 | strikethrough: false, 124 | underline: false, 125 | code: true, 126 | color: 'default', 127 | }); 128 | 129 | expect(ObjectTransformers.text(object)).to.equal('`abc`'); 130 | }); 131 | 132 | it("Should return '***~~`abc`~~***' when bold, italic, strikethrough, and code annotations are true", () => { 133 | const object = makeTextObjectWithAnnotations({ 134 | bold: true, 135 | italic: true, 136 | strikethrough: true, 137 | underline: false, 138 | code: true, 139 | color: 'default', 140 | }); 141 | 142 | expect(ObjectTransformers.text(object)).to.equal('***~~`abc`~~***'); 143 | }); 144 | }); 145 | 146 | describe("When content is 'abc' and link.url is 'http://example.com'", () => { 147 | it("Should return '**[abc](http://example.com)**' when only the bold annotation is true", () => { 148 | const object: TextObject = { 149 | type: 'text', 150 | text: { 151 | content: 'abc', 152 | link: { 153 | url: 'http://example.com', 154 | }, 155 | }, 156 | annotations: { 157 | bold: true, 158 | italic: false, 159 | strikethrough: false, 160 | underline: false, 161 | code: false, 162 | color: 'default', 163 | }, 164 | plain_text: 'abc', 165 | href: 'http://example.com', 166 | }; 167 | 168 | expect(ObjectTransformers.text(object)).to.equal('**[abc](http://example.com)**'); 169 | }); 170 | }); 171 | }); 172 | 173 | describe('mention', () => { 174 | describe('When all annotations are off', () => { 175 | function makeMentionObjectForDate(date: DateObject, href: string | null): MentionObject { 176 | return { 177 | type: 'mention', 178 | mention: date, 179 | annotations: { 180 | bold: false, 181 | italic: false, 182 | strikethrough: false, 183 | underline: false, 184 | code: false, 185 | color: 'default', 186 | }, 187 | plain_text: '@today', 188 | href: href, 189 | }; 190 | } 191 | 192 | function makeMentionObjectForUser(user: UserObject, plainText: string, href: string | null): MentionObject { 193 | return { 194 | type: 'mention', 195 | mention: user, 196 | annotations: { 197 | bold: false, 198 | italic: false, 199 | strikethrough: false, 200 | underline: false, 201 | code: false, 202 | color: 'default', 203 | }, 204 | plain_text: plainText, 205 | href: href, 206 | }; 207 | } 208 | 209 | describe('When mention.href is null', () => { 210 | describe("When mention.type is 'user'", () => { 211 | describe('When the user is anonymous (i.e only the id and object fields are given)', () => { 212 | it("Should return '@hello' when mention.plain_text is @hello", () => { 213 | const object = makeMentionObjectForUser( 214 | { 215 | type: 'user', 216 | user: { 217 | id: '', 218 | object: 'user', 219 | }, 220 | }, 221 | '@hello', 222 | null, 223 | ); 224 | 225 | expect(ObjectTransformers.mention(object)).to.equal('@hello'); 226 | }); 227 | }); 228 | 229 | describe('When the user is a person', () => { 230 | it("Should return 'abc' when person name is 'abc'", () => { 231 | const object = makeMentionObjectForUser( 232 | { 233 | type: 'user', 234 | user: { 235 | type: 'person', 236 | person: { 237 | email: 'abc@c.com', 238 | }, 239 | name: 'abc', 240 | avatar_url: 'example.com', 241 | id: 'xxxr', 242 | object: 'user', 243 | }, 244 | }, 245 | '', 246 | null, 247 | ); 248 | 249 | expect(ObjectTransformers.mention(object)).to.equal('abc'); 250 | }); 251 | }); 252 | 253 | describe('When the user is a bot', () => { 254 | it("Should return 'efg' when bot name is 'efg'", () => { 255 | const object = makeMentionObjectForUser( 256 | { 257 | type: 'user', 258 | user: { 259 | type: 'bot', 260 | name: 'efg', 261 | avatar_url: 'example.com', 262 | id: 'xxxr', 263 | object: 'user', 264 | }, 265 | }, 266 | '', 267 | null, 268 | ); 269 | 270 | expect(ObjectTransformers.mention(object)).to.equal('efg'); 271 | }); 272 | }); 273 | }); 274 | 275 | describe("When mention.type is 'date'", () => { 276 | describe("When the start field is '2020-12-08T12:00:00Z'", () => { 277 | it("Should return '2020-12-08T12:00:00Z' when the end field is null", () => { 278 | const object = makeMentionObjectForDate( 279 | { 280 | type: 'date', 281 | date: { 282 | start: '2020-12-08T12:00:00Z', 283 | end: null, 284 | }, 285 | }, 286 | null, 287 | ); 288 | 289 | expect(ObjectTransformers.mention(object)).to.equal('2020-12-08T12:00:00Z'); 290 | }); 291 | 292 | it("Should return '2020-12-08T12:00:00Z to '2022-12-08T12:00:00Z' when the end field is '2022-12-08T12:00:00Z'", () => { 293 | const object = makeMentionObjectForDate( 294 | { 295 | type: 'date', 296 | date: { 297 | start: '2020-12-08T12:00:00Z', 298 | end: '2022-12-08T12:00:00Z', 299 | }, 300 | }, 301 | null, 302 | ); 303 | 304 | expect(ObjectTransformers.mention(object)).to.equal('2020-12-08T12:00:00Z to 2022-12-08T12:00:00Z'); 305 | }); 306 | }); 307 | }); 308 | }); 309 | 310 | describe('When mention.href is NOT null', () => { 311 | describe("When mention.type is 'user'", () => { 312 | describe('When the user is a bot', () => { 313 | it("Should return '[efg](example.com)' when bot name is 'efg' and href is 'example.com'", () => { 314 | const object = makeMentionObjectForUser( 315 | { 316 | type: 'user', 317 | user: { 318 | type: 'bot', 319 | name: 'efg', 320 | avatar_url: 'example.com', 321 | id: 'xxxr', 322 | object: 'user', 323 | }, 324 | }, 325 | '', 326 | 'example.com', 327 | ); 328 | 329 | expect(ObjectTransformers.mention(object)).to.equal('[efg](example.com)'); 330 | }); 331 | }); 332 | }); 333 | 334 | describe("When mention.type is 'date'", () => { 335 | describe("When the start field is '2020-12-08T12:00:00Z'", () => { 336 | it("Should return '[2020-12-08T12:00:00Z](example.com)' when the end field is null and href is 'example.com'", () => { 337 | const object = makeMentionObjectForDate( 338 | { 339 | type: 'date', 340 | date: { 341 | start: '2020-12-08T12:00:00Z', 342 | end: null, 343 | }, 344 | }, 345 | 'example.com', 346 | ); 347 | 348 | expect(ObjectTransformers.mention(object)).to.equal('[2020-12-08T12:00:00Z](example.com)'); 349 | }); 350 | }); 351 | }); 352 | 353 | describe("When mention.type is 'page'", () => { 354 | function makeMentionObjectForPage(page: PageMentionObject, plainText: string): MentionObject { 355 | return { 356 | type: 'mention', 357 | mention: page, 358 | annotations: { 359 | bold: false, 360 | italic: false, 361 | strikethrough: false, 362 | underline: false, 363 | code: false, 364 | color: 'default', 365 | }, 366 | plain_text: plainText, 367 | href: 'rrr', 368 | }; 369 | } 370 | 371 | it("Should return [dub](:::pathTo:::abc-def:::) when linked to page with id 'abc-def' and plain_text is 'dub'", () => { 372 | const object = makeMentionObjectForPage( 373 | { 374 | type: 'page', 375 | page: { 376 | id: 'abc-def', 377 | }, 378 | }, 379 | 'dub', 380 | ); 381 | 382 | expect(ObjectTransformers.mention(object)).to.equal('[dub](:::pathTo:::abc-def:::)'); 383 | }); 384 | }); 385 | 386 | describe("When mention.type is 'database'", () => { 387 | function makeMentionObjectForDatabase( 388 | database: DatabaseMentionObject, 389 | plainText: string, 390 | href: string, 391 | ): MentionObject { 392 | return { 393 | type: 'mention', 394 | mention: database, 395 | annotations: { 396 | bold: false, 397 | italic: false, 398 | strikethrough: false, 399 | underline: false, 400 | code: false, 401 | color: 'default', 402 | }, 403 | plain_text: plainText, 404 | href: href, 405 | }; 406 | } 407 | 408 | it("Should return [abc](efg) when plain_text is 'abc' and href is 'efg'", () => { 409 | const object = makeMentionObjectForDatabase( 410 | { 411 | type: 'database', 412 | database: { 413 | id: 'fff', 414 | }, 415 | }, 416 | 'abc', 417 | 'efg', 418 | ); 419 | 420 | expect(ObjectTransformers.mention(object)).to.equal('[abc](efg)'); 421 | }); 422 | }); 423 | }); 424 | }); 425 | 426 | describe("When mention.type is 'date' ", () => { 427 | describe("When the start field is '2020-12-08T12:00:00Z', the end field is null, and the href is 'example.com'", () => { 428 | function makeMentionObjectForDateWithAnnotations(annotations: { 429 | bold: boolean; 430 | italic: boolean; 431 | strikethrough: boolean; 432 | underline: boolean; 433 | code: boolean; 434 | color: 'default'; 435 | }): MentionObject { 436 | return { 437 | type: 'mention', 438 | mention: { 439 | type: 'date', 440 | date: { 441 | start: '2020-12-08T12:00:00Z', 442 | end: null, 443 | }, 444 | }, 445 | annotations: annotations, 446 | plain_text: '@today', 447 | href: 'example.com', 448 | }; 449 | } 450 | 451 | it("Should return '**[2020-12-08T12:00:00Z](example.com)**' when only the bold annotation is true", () => { 452 | const object = makeMentionObjectForDateWithAnnotations({ 453 | bold: true, 454 | italic: false, 455 | strikethrough: false, 456 | underline: false, 457 | code: false, 458 | color: 'default', 459 | }); 460 | 461 | expect(ObjectTransformers.mention(object)).to.equal('**[2020-12-08T12:00:00Z](example.com)**'); 462 | }); 463 | 464 | it("Should return '*[2020-12-08T12:00:00Z](example.com)*' when only the italic annotation is true", () => { 465 | const object = makeMentionObjectForDateWithAnnotations({ 466 | bold: false, 467 | italic: true, 468 | strikethrough: false, 469 | underline: false, 470 | code: false, 471 | color: 'default', 472 | }); 473 | 474 | expect(ObjectTransformers.mention(object)).to.equal('*[2020-12-08T12:00:00Z](example.com)*'); 475 | }); 476 | 477 | it("Should return '~~[2020-12-08T12:00:00Z](example.com)~~' when only the strikethrough annotation is true", () => { 478 | const object = makeMentionObjectForDateWithAnnotations({ 479 | bold: false, 480 | italic: false, 481 | strikethrough: true, 482 | underline: false, 483 | code: false, 484 | color: 'default', 485 | }); 486 | 487 | expect(ObjectTransformers.mention(object)).to.equal('~~[2020-12-08T12:00:00Z](example.com)~~'); 488 | }); 489 | 490 | it("Should return '`[2020-12-08T12:00:00Z](example.com)`' when only the code annotation is true", () => { 491 | const object = makeMentionObjectForDateWithAnnotations({ 492 | bold: false, 493 | italic: false, 494 | strikethrough: false, 495 | underline: false, 496 | code: true, 497 | color: 'default', 498 | }); 499 | 500 | expect(ObjectTransformers.mention(object)).to.equal('`[2020-12-08T12:00:00Z](example.com)`'); 501 | }); 502 | 503 | it("Should return '***~~`[2020-12-08T12:00:00Z](example.com)`~~***' when the bold, italic, strikethrough and code annotations are true", () => { 504 | const object = makeMentionObjectForDateWithAnnotations({ 505 | bold: true, 506 | italic: true, 507 | strikethrough: true, 508 | underline: false, 509 | code: true, 510 | color: 'default', 511 | }); 512 | 513 | expect(ObjectTransformers.mention(object)).to.equal('***~~`[2020-12-08T12:00:00Z](example.com)`~~***'); 514 | }); 515 | }); 516 | }); 517 | }); 518 | 519 | describe('equation', () => { 520 | describe('When all annotations are off', () => { 521 | describe('When href is null', () => { 522 | function makeEquationObject(expression: string): EquationObject { 523 | return { 524 | type: 'equation', 525 | equation: { 526 | expression: expression, 527 | }, 528 | annotations: { 529 | bold: false, 530 | italic: false, 531 | strikethrough: false, 532 | underline: false, 533 | code: false, 534 | color: 'default', 535 | }, 536 | plain_text: expression, 537 | href: null, 538 | }; 539 | } 540 | 541 | it("Should return '$abc$' when expression is 'abc'", () => { 542 | const object = makeEquationObject('abc'); 543 | expect(ObjectTransformers.equation(object)).to.equal('$abc$'); 544 | }); 545 | }); 546 | 547 | describe("When href is 'example.com'", () => { 548 | function makeEquationObject(expression: string, href: 'example.com'): EquationObject { 549 | return { 550 | type: 'equation', 551 | equation: { 552 | expression: expression, 553 | }, 554 | annotations: { 555 | bold: false, 556 | italic: false, 557 | strikethrough: false, 558 | underline: false, 559 | code: false, 560 | color: 'default', 561 | }, 562 | plain_text: expression, 563 | href: href, 564 | }; 565 | } 566 | 567 | it("Should return '[$abc$](example.com)' when expression is 'abc'", () => { 568 | const object = makeEquationObject('abc', 'example.com'); 569 | expect(ObjectTransformers.equation(object)).to.equal('[$abc$](example.com)'); 570 | }); 571 | }); 572 | }); 573 | 574 | describe("When href is 'example.com'", () => { 575 | function makeEquationObjectWithAnnotations( 576 | annotations: { 577 | bold: boolean; 578 | italic: boolean; 579 | strikethrough: boolean; 580 | underline: boolean; 581 | code: boolean; 582 | color: 'default'; 583 | }, 584 | expression: string, 585 | ): EquationObject { 586 | return { 587 | type: 'equation', 588 | equation: { 589 | expression: expression, 590 | }, 591 | annotations: annotations, 592 | plain_text: expression, 593 | href: 'example.com', 594 | }; 595 | } 596 | 597 | it("Should return '**[$abc$](example.com)**' when only the bold annotation is true", () => { 598 | const object = makeEquationObjectWithAnnotations( 599 | { 600 | bold: true, 601 | italic: false, 602 | strikethrough: false, 603 | underline: false, 604 | code: false, 605 | color: 'default', 606 | }, 607 | 'abc', 608 | ); 609 | 610 | expect(ObjectTransformers.equation(object)).to.equal('**[$abc$](example.com)**'); 611 | }); 612 | 613 | it("Should return '*[$abc$](example.com)*' when only the italic annotation is true", () => { 614 | const object = makeEquationObjectWithAnnotations( 615 | { 616 | bold: false, 617 | italic: true, 618 | strikethrough: false, 619 | underline: false, 620 | code: false, 621 | color: 'default', 622 | }, 623 | 'abc', 624 | ); 625 | 626 | expect(ObjectTransformers.equation(object)).to.equal('*[$abc$](example.com)*'); 627 | }); 628 | 629 | it("Should return '~~[$abc$](example.com)~~' when only the strikethrough annotation is true", () => { 630 | const object = makeEquationObjectWithAnnotations( 631 | { 632 | bold: false, 633 | italic: false, 634 | strikethrough: true, 635 | underline: false, 636 | code: false, 637 | color: 'default', 638 | }, 639 | 'abc', 640 | ); 641 | 642 | expect(ObjectTransformers.equation(object)).to.equal('~~[$abc$](example.com)~~'); 643 | }); 644 | 645 | it("Should return '`[$abc$](example.com)`' when only the code annotation is true", () => { 646 | const object = makeEquationObjectWithAnnotations( 647 | { 648 | bold: false, 649 | italic: false, 650 | strikethrough: false, 651 | underline: false, 652 | code: true, 653 | color: 'default', 654 | }, 655 | 'abc', 656 | ); 657 | 658 | expect(ObjectTransformers.equation(object)).to.equal('`[$abc$](example.com)`'); 659 | }); 660 | 661 | it("Should return '***~~`[$abc$](example.com)`~~***' when the bold, italic, strikethrough, and code annotations are true", () => { 662 | const object = makeEquationObjectWithAnnotations( 663 | { 664 | bold: true, 665 | italic: true, 666 | strikethrough: true, 667 | underline: false, 668 | code: true, 669 | color: 'default', 670 | }, 671 | 'abc', 672 | ); 673 | 674 | expect(ObjectTransformers.equation(object)).to.equal('***~~`[$abc$](example.com)`~~***'); 675 | }); 676 | }); 677 | }); 678 | 679 | describe('transform_all', () => { 680 | it('Should transform all objects with the text, mention, and equation ObjectTransfromers and merge their outputs', () => { 681 | ObjectTransformers.text = (_) => 'text object, '; 682 | ObjectTransformers.mention = (_) => 'mention object, '; 683 | ObjectTransformers.equation = (_) => 'equation object'; 684 | 685 | const objects = [ 686 | { 687 | type: 'text', 688 | }, 689 | { 690 | type: 'mention', 691 | }, 692 | { 693 | type: 'equation', 694 | }, 695 | ] as any[]; 696 | 697 | expect(ObjectTransformers.transform_all(objects)).to.equal('text object, mention object, equation object'); 698 | }); 699 | }); 700 | 701 | it('Should return "" when objects is undefined', () => { 702 | const objects: any = undefined; 703 | expect(ObjectTransformers.transform_all(objects)).to.equal(''); 704 | }); 705 | }); 706 | 707 | it('Should "" when objects is null', () => { 708 | const objects: any = null; 709 | expect(ObjectTransformers.transform_all(objects)).to.equal(''); 710 | }); 711 | 712 | describe('BlockTransformers tests', () => { 713 | describe('paragraph', () => { 714 | it("Should return 'abc' when ObjectTransformers.transform_all returns 'abc'", () => { 715 | ObjectTransformers.transform_all = (_) => 'abc'; 716 | 717 | const block: GetBlockResponse = { 718 | type: 'paragraph', 719 | paragraph: { 720 | rich_text: [], 721 | color: 'default', 722 | }, 723 | object: 'block', 724 | id: '', 725 | created_time: '', 726 | last_edited_time: '', 727 | has_children: false, 728 | archived: false, 729 | }; 730 | 731 | expect(BlockTransformers.paragraph(block, {} as any)).to.equal('abc'); 732 | }); 733 | }); 734 | 735 | describe('heading_1', () => { 736 | it("Should return '# abc' when ObjectTransformers.transform_all returns 'abc'", () => { 737 | ObjectTransformers.transform_all = (_) => 'abc'; 738 | 739 | const block: GetBlockResponse = { 740 | type: 'heading_1', 741 | heading_1: { 742 | rich_text: [], 743 | color: 'default', 744 | }, 745 | object: 'block', 746 | id: '', 747 | created_time: '', 748 | last_edited_time: '', 749 | has_children: false, 750 | archived: false, 751 | }; 752 | 753 | expect(BlockTransformers.heading_1(block, {} as any)).to.equal('# abc'); 754 | }); 755 | }); 756 | 757 | describe('heading_2', () => { 758 | it("Should return '## abc' when ObjectTransformers.transform_all returns 'abc'", () => { 759 | ObjectTransformers.transform_all = (_) => 'abc'; 760 | 761 | const block: GetBlockResponse = { 762 | type: 'heading_2', 763 | heading_2: { 764 | rich_text: [], 765 | color: 'default', 766 | }, 767 | object: 'block', 768 | id: '', 769 | created_time: '', 770 | last_edited_time: '', 771 | has_children: false, 772 | archived: false, 773 | }; 774 | 775 | expect(BlockTransformers.heading_2(block, {} as any)).to.equal('## abc'); 776 | }); 777 | }); 778 | 779 | describe('heading_3', () => { 780 | it("Should return '### abc' when ObjectTransformers.transform_all returns 'abc'", () => { 781 | ObjectTransformers.transform_all = (_) => 'abc'; 782 | 783 | const block: GetBlockResponse = { 784 | type: 'heading_3', 785 | heading_3: { 786 | rich_text: [], 787 | color: 'default', 788 | }, 789 | object: 'block', 790 | id: '', 791 | created_time: '', 792 | last_edited_time: '', 793 | has_children: false, 794 | archived: false, 795 | }; 796 | 797 | expect(BlockTransformers.heading_3(block, {} as any)).to.equal('### abc'); 798 | }); 799 | }); 800 | 801 | describe('bulleted_list_item', () => { 802 | it("Should return '- abc' when ObjectTransformers.transform_all returns 'abc'", () => { 803 | ObjectTransformers.transform_all = (_) => 'abc'; 804 | 805 | const block: GetBlockResponse = { 806 | type: 'bulleted_list_item', 807 | bulleted_list_item: { 808 | rich_text: [], 809 | color: 'default', 810 | }, 811 | object: 'block', 812 | id: '', 813 | created_time: '', 814 | last_edited_time: '', 815 | has_children: false, 816 | archived: false, 817 | }; 818 | 819 | expect(BlockTransformers.bulleted_list_item(block, {} as any)).to.equal('- abc'); 820 | }); 821 | }); 822 | 823 | describe('numbered_list_item', () => { 824 | it("Should return '1. abc' when ObjectTransformers.transform_all returns 'abc'", () => { 825 | ObjectTransformers.transform_all = (_) => 'abc'; 826 | 827 | const block: GetBlockResponse = { 828 | type: 'numbered_list_item', 829 | numbered_list_item: { 830 | rich_text: [], 831 | color: 'default', 832 | }, 833 | object: 'block', 834 | id: '', 835 | created_time: '', 836 | last_edited_time: '', 837 | has_children: false, 838 | archived: false, 839 | }; 840 | 841 | expect(BlockTransformers.numbered_list_item(block, {} as any)).to.equal('1. abc'); 842 | }); 843 | }); 844 | 845 | describe('to_do', () => { 846 | it("Should return '- [ ] abc' when ObjectTransformers.transform_all returns 'abc' and checked is false", () => { 847 | ObjectTransformers.transform_all = (_) => 'abc'; 848 | 849 | const block: GetBlockResponse = { 850 | type: 'to_do', 851 | to_do: { 852 | rich_text: [], 853 | color: 'default', 854 | checked: false, 855 | }, 856 | object: 'block', 857 | id: '', 858 | created_time: '', 859 | last_edited_time: '', 860 | has_children: false, 861 | archived: false, 862 | }; 863 | 864 | expect(BlockTransformers.to_do(block, {} as any)).to.equal('- [ ] abc'); 865 | }); 866 | 867 | it("Should return '- [X] abc' when ObjectTransformers.transform_all returns 'abc' and checked is true", () => { 868 | ObjectTransformers.transform_all = (_) => 'abc'; 869 | 870 | const block: GetBlockResponse = { 871 | type: 'to_do', 872 | to_do: { 873 | rich_text: [], 874 | color: 'default', 875 | checked: true, 876 | }, 877 | object: 'block', 878 | id: '', 879 | created_time: '', 880 | last_edited_time: '', 881 | has_children: false, 882 | archived: false, 883 | }; 884 | 885 | expect(BlockTransformers.to_do(block, {} as any)).to.equal('- [X] abc'); 886 | }); 887 | }); 888 | 889 | describe('toggle', () => { 890 | it("Should return 'abc' when ObjectTransformers.transform_all returns 'abc'", () => { 891 | const block: GetBlockResponse = { 892 | type: 'toggle', 893 | toggle: { 894 | rich_text: [], 895 | color: 'default', 896 | }, 897 | object: 'block', 898 | id: '', 899 | created_time: '', 900 | last_edited_time: '', 901 | has_children: false, 902 | archived: false, 903 | }; 904 | 905 | expect(BlockTransformers.toggle(block, {} as any)).to.equal('abc'); 906 | }); 907 | }); 908 | 909 | describe('child_page', () => { 910 | it("Should return '[abc](https://www.notion.so/efghijklm)' when page title is 'abc' and id is 'efg-hij-klm'", () => { 911 | const block: GetBlockResponse = { 912 | type: 'child_page', 913 | child_page: { 914 | title: 'abc', 915 | }, 916 | object: 'block', 917 | id: 'efg-hij-klm', 918 | created_time: '', 919 | last_edited_time: '', 920 | has_children: false, 921 | archived: false, 922 | }; 923 | 924 | expect(BlockTransformers.child_page(block, {} as any)).to.equal('[abc](https://www.notion.so/efghijklm)'); 925 | }); 926 | }); 927 | 928 | describe('child_database', () => { 929 | it("Should return '[abc](https://www.notion.so/efghijklm)' when database title is 'abc' and id is 'efg-hij-klm'", () => { 930 | const block: GetBlockResponse = { 931 | type: 'child_database', 932 | child_database: { 933 | title: 'abc', 934 | }, 935 | object: 'block', 936 | id: 'efg-hij-klm', 937 | created_time: '', 938 | last_edited_time: '', 939 | has_children: false, 940 | archived: false, 941 | }; 942 | 943 | expect(BlockTransformers.child_database(block, {} as any)).to.equal('[abc](https://www.notion.so/efghijklm)'); 944 | }); 945 | }); 946 | 947 | describe('embed', () => { 948 | it("Should return \"[abc](example.com ':include')\" when ObjectTransformers.transform_all returns 'abc' for caption and embed url is 'example.com'", () => { 949 | ObjectTransformers.transform_all = (_) => 'abc'; 950 | 951 | const block: GetBlockResponse = { 952 | type: 'embed', 953 | embed: { 954 | url: 'example.com', 955 | caption: [], 956 | }, 957 | object: 'block', 958 | id: '', 959 | created_time: '', 960 | last_edited_time: '', 961 | has_children: false, 962 | archived: false, 963 | }; 964 | 965 | expect(BlockTransformers.embed(block, {} as any)).to.equal("[abc](example.com ':include')"); 966 | }); 967 | }); 968 | 969 | describe('image', () => { 970 | it('Should return ![abc](example.com "abc") when getMedia returns src=example.com and captionMarkdown=abc', () => { 971 | ObjectTransformers.transform_all = (_) => 'abc'; 972 | 973 | const block: GetBlockResponse = { 974 | type: 'image', 975 | image: { 976 | type: 'external', 977 | external: { 978 | url: 'example.com', 979 | }, 980 | caption: [], 981 | }, 982 | object: 'block', 983 | id: '', 984 | created_time: '', 985 | last_edited_time: '', 986 | has_children: false, 987 | archived: false, 988 | }; 989 | 990 | expect(BlockTransformers.image(block, {} as any)).to.equal('![abc](example.com "abc")'); 991 | }); 992 | }); 993 | 994 | describe('video', () => { 995 | it('Should return "[abc](example.com \':include\')" when getMedia returns src=example.com and captionMarkdown=abc', () => { 996 | ObjectTransformers.transform_all = (_) => 'abc'; 997 | 998 | const block: GetBlockResponse = { 999 | type: 'video', 1000 | video: { 1001 | type: 'external', 1002 | external: { 1003 | url: 'example.com', 1004 | }, 1005 | caption: [], 1006 | }, 1007 | object: 'block', 1008 | id: '', 1009 | created_time: '', 1010 | last_edited_time: '', 1011 | has_children: false, 1012 | archived: false, 1013 | }; 1014 | 1015 | expect(BlockTransformers.video(block, {} as any)).to.equal("[abc](example.com ':include')"); 1016 | }); 1017 | }); 1018 | 1019 | describe('file', () => { 1020 | it('Should return "[abc](example.com \':include\')" when getMedia returns src=example.com and captionMarkdown=abc', () => { 1021 | ObjectTransformers.transform_all = (_) => 'abc'; 1022 | 1023 | const block: GetBlockResponse = { 1024 | type: 'file', 1025 | file: { 1026 | type: 'external', 1027 | external: { 1028 | url: 'example.com', 1029 | }, 1030 | caption: [], 1031 | }, 1032 | object: 'block', 1033 | id: '', 1034 | created_time: '', 1035 | last_edited_time: '', 1036 | has_children: false, 1037 | archived: false, 1038 | }; 1039 | 1040 | expect(BlockTransformers.file(block, {} as any)).to.equal("[abc](example.com ':include')"); 1041 | }); 1042 | }); 1043 | 1044 | describe('pdf', () => { 1045 | it('Should return "[abc](example.com \':include\')" when getMedia returns src=example.com and captionMarkdown=abc', () => { 1046 | ObjectTransformers.transform_all = (_) => 'abc'; 1047 | 1048 | const block: GetBlockResponse = { 1049 | type: 'pdf', 1050 | pdf: { 1051 | type: 'external', 1052 | external: { 1053 | url: 'example.com', 1054 | }, 1055 | caption: [], 1056 | }, 1057 | object: 'block', 1058 | id: '', 1059 | created_time: '', 1060 | last_edited_time: '', 1061 | has_children: false, 1062 | archived: false, 1063 | }; 1064 | 1065 | expect(BlockTransformers.pdf(block, {} as any)).to.equal("[abc](example.com ':include')"); 1066 | }); 1067 | }); 1068 | 1069 | describe('audio', () => { 1070 | it('Should return "[abc](example.com \':include\')" when getMedia returns src=example.com and captionMarkdown=abc', () => { 1071 | ObjectTransformers.transform_all = (_) => 'abc'; 1072 | 1073 | const block: GetBlockResponse = { 1074 | type: 'audio', 1075 | audio: { 1076 | type: 'external', 1077 | external: { 1078 | url: 'example.com', 1079 | }, 1080 | caption: [], 1081 | }, 1082 | object: 'block', 1083 | id: '', 1084 | created_time: '', 1085 | last_edited_time: '', 1086 | has_children: false, 1087 | archived: false, 1088 | }; 1089 | 1090 | expect(BlockTransformers.audio(block, {} as any)).to.equal("[abc](example.com ':include')"); 1091 | }); 1092 | }); 1093 | 1094 | describe('bookmark', () => { 1095 | it("Should return '[example.com](example.com)' when url is 'example.com'", () => { 1096 | const block: GetBlockResponse = { 1097 | type: 'bookmark', 1098 | bookmark: { 1099 | url: 'example.com', 1100 | caption: [], 1101 | }, 1102 | object: 'block', 1103 | id: '', 1104 | created_time: '', 1105 | last_edited_time: '', 1106 | has_children: false, 1107 | archived: false, 1108 | }; 1109 | 1110 | expect(BlockTransformers.bookmark(block, {} as any)).to.equal('[example.com](example.com)'); 1111 | }); 1112 | }); 1113 | 1114 | describe('callout', () => { 1115 | describe('When icon is emoji', () => { 1116 | it("Should return '> 😉 abc' when emoji is '😉' and ObjectTransformers.transform_all returns 'abc' for callout text", () => { 1117 | ObjectTransformers.transform_all = (_) => 'abc'; 1118 | 1119 | const block: GetBlockResponse = { 1120 | type: 'callout', 1121 | callout: { 1122 | icon: { 1123 | type: 'emoji', 1124 | emoji: '😉', 1125 | }, 1126 | rich_text: [], 1127 | color: 'default', 1128 | }, 1129 | object: 'block', 1130 | id: '', 1131 | created_time: '', 1132 | last_edited_time: '', 1133 | has_children: false, 1134 | archived: false, 1135 | }; 1136 | 1137 | expect(BlockTransformers.callout(block, {} as any)).to.equal('> 😉 abc'); 1138 | }); 1139 | }); 1140 | 1141 | describe('When icon is external file', () => { 1142 | it("Should return '> abc' when ObjectTransformers.transform_all returns 'abc' for callout text", () => { 1143 | ObjectTransformers.transform_all = (_) => 'abc'; 1144 | 1145 | const block: GetBlockResponse = { 1146 | type: 'callout', 1147 | callout: { 1148 | icon: { 1149 | type: 'external', 1150 | external: { 1151 | url: 'example.com', 1152 | }, 1153 | }, 1154 | rich_text: [], 1155 | color: 'default', 1156 | }, 1157 | object: 'block', 1158 | id: '', 1159 | created_time: '', 1160 | last_edited_time: '', 1161 | has_children: false, 1162 | archived: false, 1163 | }; 1164 | 1165 | expect(BlockTransformers.callout(block, {} as any)).to.equal('> abc'); 1166 | }); 1167 | }); 1168 | 1169 | describe('When icon is file', () => { 1170 | it("Should return '> abc' when ObjectTransformers.transform_all returns 'abc' for callout text", () => { 1171 | ObjectTransformers.transform_all = (_) => 'abc'; 1172 | 1173 | const block: GetBlockResponse = { 1174 | type: 'callout', 1175 | callout: { 1176 | icon: { 1177 | type: 'file', 1178 | file: { 1179 | url: 'example.com', 1180 | expiry_time: '', 1181 | }, 1182 | }, 1183 | rich_text: [], 1184 | color: 'default', 1185 | }, 1186 | object: 'block', 1187 | id: '', 1188 | created_time: '', 1189 | last_edited_time: '', 1190 | has_children: false, 1191 | archived: false, 1192 | }; 1193 | 1194 | expect(BlockTransformers.callout(block, {} as any)).to.equal('> abc'); 1195 | }); 1196 | }); 1197 | 1198 | describe('When icon is null', () => { 1199 | it("Should return '> abc' when ObjectTransformers.transform_all returns 'abc' for callout text", () => { 1200 | ObjectTransformers.transform_all = (_) => 'abc'; 1201 | 1202 | const block: GetBlockResponse = { 1203 | type: 'callout', 1204 | callout: { 1205 | icon: null, 1206 | rich_text: [], 1207 | color: 'default', 1208 | }, 1209 | object: 'block', 1210 | id: '', 1211 | created_time: '', 1212 | last_edited_time: '', 1213 | has_children: false, 1214 | archived: false, 1215 | }; 1216 | 1217 | expect(BlockTransformers.callout(block, {} as any)).to.equal('> abc'); 1218 | }); 1219 | }); 1220 | }); 1221 | 1222 | describe('quote', () => { 1223 | it("Should return '> abc' when ObjectTransformers.transform_all returns 'abc' for quote text", () => { 1224 | ObjectTransformers.transform_all = (_) => 'abc'; 1225 | 1226 | const block: GetBlockResponse = { 1227 | type: 'quote', 1228 | quote: { 1229 | rich_text: [], 1230 | color: 'default', 1231 | }, 1232 | object: 'block', 1233 | id: '', 1234 | created_time: '', 1235 | last_edited_time: '', 1236 | has_children: false, 1237 | archived: false, 1238 | }; 1239 | 1240 | expect(BlockTransformers.quote(block, {} as any)).to.equal('> abc'); 1241 | }); 1242 | }); 1243 | 1244 | describe('equation', () => { 1245 | it("Should return '$$abc$$' when expression is 'abc'", () => { 1246 | ObjectTransformers.transform_all = (_) => 'abc'; 1247 | 1248 | const block: GetBlockResponse = { 1249 | type: 'equation', 1250 | equation: { 1251 | expression: 'abc', 1252 | }, 1253 | object: 'block', 1254 | id: '', 1255 | created_time: '', 1256 | last_edited_time: '', 1257 | has_children: false, 1258 | archived: false, 1259 | }; 1260 | 1261 | expect(BlockTransformers.equation(block, {} as any)).to.equal('$$abc$$'); 1262 | }); 1263 | }); 1264 | 1265 | describe('divider', () => { 1266 | it("Should return '---'", () => { 1267 | const block: GetBlockResponse = { 1268 | type: 'divider', 1269 | divider: {}, 1270 | object: 'block', 1271 | id: '', 1272 | created_time: '', 1273 | last_edited_time: '', 1274 | has_children: false, 1275 | archived: false, 1276 | }; 1277 | 1278 | expect(BlockTransformers.divider(block, {} as any)).to.equal('---'); 1279 | }); 1280 | }); 1281 | 1282 | describe('table_of_contents', () => { 1283 | it("Should return ''", () => { 1284 | const block: GetBlockResponse = { 1285 | type: 'table_of_contents', 1286 | table_of_contents: { 1287 | color: 'default', 1288 | }, 1289 | object: 'block', 1290 | id: '', 1291 | created_time: '', 1292 | last_edited_time: '', 1293 | has_children: false, 1294 | archived: false, 1295 | }; 1296 | 1297 | expect(BlockTransformers.table_of_contents(block, {} as any)).to.equal(''); 1298 | }); 1299 | }); 1300 | 1301 | describe('breadcrumb', () => { 1302 | it("Should return ''", () => { 1303 | const block: GetBlockResponse = { 1304 | type: 'breadcrumb', 1305 | breadcrumb: {}, 1306 | object: 'block', 1307 | id: '', 1308 | created_time: '', 1309 | last_edited_time: '', 1310 | has_children: false, 1311 | archived: false, 1312 | }; 1313 | 1314 | expect(BlockTransformers.breadcrumb(block, {} as any)).to.equal(''); 1315 | }); 1316 | }); 1317 | 1318 | describe('code', () => { 1319 | it("Should return '```java\nabc\n```' when the language is 'java' and ObjectTransformers.transform_all returns 'abc' for the code text", () => { 1320 | ObjectTransformers.transform_all = (_) => 'abc'; 1321 | 1322 | const block: GetBlockResponse = { 1323 | type: 'code', 1324 | code: { 1325 | language: 'java', 1326 | rich_text: [], 1327 | caption: [], 1328 | }, 1329 | object: 'block', 1330 | id: '', 1331 | created_time: '', 1332 | last_edited_time: '', 1333 | has_children: false, 1334 | archived: false, 1335 | }; 1336 | 1337 | expect(BlockTransformers.code(block, {} as any)).to.equal('```java\nabc\n```'); 1338 | }); 1339 | }); 1340 | 1341 | describe('unsupported', () => { 1342 | it("Should return 'Unsupported'", () => { 1343 | const block: GetBlockResponse = { 1344 | type: 'unsupported', 1345 | unsupported: {}, 1346 | object: 'block', 1347 | id: '', 1348 | created_time: '', 1349 | last_edited_time: '', 1350 | has_children: false, 1351 | archived: false, 1352 | }; 1353 | 1354 | expect(BlockTransformers.unsupported(block, {} as any)).to.equal('Unsupported'); 1355 | }); 1356 | }); 1357 | }); 1358 | 1359 | describe('getMedia tests', () => { 1360 | describe("When type is 'external'", () => { 1361 | it('Should return src=file url and captionMarkdown=the value returned by ObjectTransformers.transform_all for file caption', () => { 1362 | ObjectTransformers.transform_all = (_) => 'abc'; 1363 | 1364 | const object: FileObject = { 1365 | type: 'external', 1366 | external: { 1367 | url: 'example.com', 1368 | }, 1369 | caption: [], 1370 | }; 1371 | 1372 | expect(getMedia(object, {} as any)).to.deep.equal({ 1373 | src: 'example.com', 1374 | captionMarkdown: 'abc', 1375 | }); 1376 | }); 1377 | }); 1378 | 1379 | describe("When type is 'file'", () => { 1380 | it('Should return src=file path as returned by MediaService.fetchMedia and captionMarkdown=the value returned by ObjectTransformers.transform_all for file caption', () => { 1381 | ObjectTransformers.transform_all = (_) => 'abc'; 1382 | setMockedMediaService({ 1383 | stageFetchRequest: (url, _) => `${url} abc`, 1384 | }); 1385 | 1386 | const object: FileObject = { 1387 | type: 'file', 1388 | file: { 1389 | url: 'example.com', 1390 | expiry_time: '', 1391 | }, 1392 | caption: [], 1393 | }; 1394 | 1395 | expect(getMedia(object, {} as any)).to.deep.equal({ 1396 | src: 'example.com abc', 1397 | captionMarkdown: 'abc', 1398 | }); 1399 | }); 1400 | }); 1401 | }); 1402 | 1403 | describe('MarkdownService tests', () => { 1404 | describe('genMarkdownForBlocks(blocks)', () => { 1405 | setup(() => { 1406 | BlockTransformers.heading_1 = (block, _) => `${(block as any).type} content`; 1407 | BlockTransformers.paragraph = (block, _) => `${(block as any).type} content`; 1408 | }); 1409 | 1410 | it('Should add an empty line between blocks', () => { 1411 | const blocks: NotionBlock[] = [ 1412 | { 1413 | data: { 1414 | type: 'heading_1', 1415 | } as any, 1416 | children: [], 1417 | }, 1418 | 1419 | { 1420 | data: { 1421 | type: 'paragraph', 1422 | } as any, 1423 | children: [], 1424 | }, 1425 | ]; 1426 | 1427 | expect(MarkdownService.instance.genMarkdownForBlocks(blocks, {} as any)).to.equal( 1428 | 'heading_1 content\n\nparagraph content\n', 1429 | ); 1430 | }); 1431 | 1432 | it('Should indent child blocks with 4 spaces', () => { 1433 | const blocks: NotionBlock[] = [ 1434 | { 1435 | data: { 1436 | type: 'paragraph', 1437 | } as any, 1438 | children: [ 1439 | { 1440 | data: { 1441 | type: 'heading_1', 1442 | } as any, 1443 | children: [ 1444 | { 1445 | data: { 1446 | type: 'paragraph', 1447 | } as any, 1448 | children: [], 1449 | }, 1450 | ], 1451 | }, 1452 | 1453 | { 1454 | data: { 1455 | type: 'heading_1', 1456 | } as any, 1457 | children: [], 1458 | }, 1459 | ], 1460 | }, 1461 | ]; 1462 | 1463 | expect(MarkdownService.instance.genMarkdownForBlocks(blocks, {} as any)).to.equal( 1464 | 'paragraph content\n\n heading_1 content\n\n paragraph content\n\n heading_1 content\n', 1465 | ); 1466 | }); 1467 | 1468 | it('Should insert an "Unknown Block: abc" when given a block of type "abc", i.e a block whose transformer is not in BlockTrasformers', () => { 1469 | const blocks: NotionBlock[] = [ 1470 | { 1471 | data: { 1472 | type: 'abc', 1473 | } as any, 1474 | children: [], 1475 | }, 1476 | ]; 1477 | 1478 | expect(MarkdownService.instance.genMarkdownForBlocks(blocks, {} as any)).to.equal('Unknown Block: abc\n'); 1479 | }); 1480 | }); 1481 | }); 1482 | -------------------------------------------------------------------------------- /test/media_service.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, setup } from 'mocha'; 2 | import { expect } from 'chai'; 3 | import { MediaDestinationController } from '../src/services/media_service'; 4 | import { setMockedFileNameService, setMockedFileSystemService } from './mocking_utils'; 5 | 6 | describe('MediaDestinationController tests', () => { 7 | function makeInstanceWithTemplateOutDir(outDir: string): MediaDestinationController { 8 | return new MediaDestinationController({ 9 | forRule: { 10 | template: 'rrrr', 11 | outDir: outDir, 12 | uses: {} as any, 13 | alsoUses: [], 14 | }, 15 | }); 16 | } 17 | 18 | describe('makeFileDestionationForAsset(args)', () => { 19 | describe("When FileNameService.genUnique returns 'file_abc'", () => { 20 | setup(() => { 21 | setMockedFileNameService({ 22 | genUnique: () => 'file_abc', 23 | }); 24 | }); 25 | 26 | describe("When instance is create with TemplateRule that has 'abc' as outDir and undefined for writeMediaTo", () => { 27 | const instance = makeInstanceWithTemplateOutDir('abc'); 28 | 29 | function assertDoesNotCreateAnyFolderWhenMediaFolderExists(url: string) { 30 | let didCreateFolder = false; 31 | 32 | setMockedFileSystemService({ 33 | doesFolderExist: (_) => true, 34 | createFolder: (_) => (didCreateFolder = true), 35 | }); 36 | 37 | instance.makeFileDestinationForAssetWithUrl(url); 38 | expect(didCreateFolder).to.be.false; 39 | } 40 | 41 | function assertCreatesMediaFolderWhenNotExist(path: string, url: string) { 42 | let createdFolder = ''; 43 | 44 | setMockedFileSystemService({ 45 | doesFolderExist: (_) => false, 46 | createFolder: (p) => (createdFolder = p), 47 | }); 48 | 49 | instance.makeFileDestinationForAssetWithUrl(url); 50 | expect(createdFolder).to.equal(path); 51 | } 52 | 53 | it("Should return 'videos/file1.mp4' when given 'https://s3.us-west-2.amazonaws.com/secure.notion-static.com/05ce5030-65a3-459b-9bac-b1020e3e2a6a/file1.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256'", () => { 54 | const url = 55 | 'https://s3.us-west-2.amazonaws.com/secure.notion-static.com/05ce5030-65a3-459b-9bac-b1020e3e2a6a/file1.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256'; 56 | expect(instance.makeFileDestinationForAssetWithUrl(url)).to.equal('videos/file_abc.mp4'); 57 | }); 58 | 59 | it("Should return 'audio/file1.mp3' when given 'https://s3.us-west-2.amazonaws.com/secure.notion-static.com/05ce5030-65a3-459b-9bac-b1020e3e2a6a/file1.mp3?X-Amz-Algorithm=AWS4-HMAC-SHA256'", () => { 60 | const url = 61 | 'https://s3.us-west-2.amazonaws.com/secure.notion-static.com/05ce5030-65a3-459b-9bac-b1020e3e2a6a/file1.mp3?X-Amz-Algorithm=AWS4-HMAC-SHA256'; 62 | expect(instance.makeFileDestinationForAssetWithUrl(url)).to.equal('audio/file_abc.mp3'); 63 | }); 64 | 65 | it("Should return 'other_media/file1' when given 'https://s3.us-west-2.amazonaws.com/secure.notion-static.com/05ce5030-65a3-459b-9bac-b1020e3e2a6a/file1?X-Amz-Algorithm=AWS4-HMAC-SHA256'", () => { 66 | const url = 67 | 'https://s3.us-west-2.amazonaws.com/secure.notion-static.com/05ce5030-65a3-459b-9bac-b1020e3e2a6a/file1?X-Amz-Algorithm=AWS4-HMAC-SHA256'; 68 | expect(instance.makeFileDestinationForAssetWithUrl(url)).to.equal('other_media/file_abc'); 69 | }); 70 | 71 | it("Should create folder 'abc/videos' if not exists when given 'https://s3.us-west-2.amazonaws.com/secure.notion-static.com/05ce5030-65a3-459b-9bac-b1020e3e2a6a/file1.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256'", () => { 72 | assertCreatesMediaFolderWhenNotExist( 73 | 'abc/videos', 74 | 'https://s3.us-west-2.amazonaws.com/secure.notion-static.com/05ce5030-65a3-459b-9bac-b1020e3e2a6a/file1.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256', 75 | ); 76 | }); 77 | 78 | it("Should NOT create folder 'abc/videos' if it exists when given 'https://s3.us-west-2.amazonaws.com/secure.notion-static.com/05ce5030-65a3-459b-9bac-b1020e3e2a6a/file1.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256'", () => { 79 | assertDoesNotCreateAnyFolderWhenMediaFolderExists( 80 | 'https://s3.us-west-2.amazonaws.com/secure.notion-static.com/05ce5030-65a3-459b-9bac-b1020e3e2a6a/file1.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256', 81 | ); 82 | }); 83 | 84 | it("Should create folder 'abc/other_media' if not exists when given 'https://s3.us-west-2.amazonaws.com/secure.notion-static.com/05ce5030-65a3-459b-9bac-b1020e3e2a6a/file1?X-Amz-Algorithm=AWS4-HMAC-SHA256'", () => { 85 | assertCreatesMediaFolderWhenNotExist( 86 | 'abc/other_media', 87 | 'https://s3.us-west-2.amazonaws.com/secure.notion-static.com/05ce5030-65a3-459b-9bac-b1020e3e2a6a/file1?X-Amz-Algorithm=AWS4-HMAC-SHA256', 88 | ); 89 | }); 90 | 91 | it("Should NOT create folder 'abc/other_media' if it exists when given 'https://s3.us-west-2.amazonaws.com/secure.notion-static.com/05ce5030-65a3-459b-9bac-b1020e3e2a6a/file1?X-Amz-Algorithm=AWS4-HMAC-SHA256'", () => { 92 | assertDoesNotCreateAnyFolderWhenMediaFolderExists( 93 | 'https://s3.us-west-2.amazonaws.com/secure.notion-static.com/05ce5030-65a3-459b-9bac-b1020e3e2a6a/file1?X-Amz-Algorithm=AWS4-HMAC-SHA256', 94 | ); 95 | }); 96 | }); 97 | }); 98 | }); 99 | 100 | describe('static getAbsoluteDestinationPath(rule, subfolderName', () => { 101 | describe("When subfolderName is 'zrc'", () => { 102 | it("Should return 'abc/zrc' when outDir is 'abc' and writeMediaTo is undefined", () => { 103 | expect(MediaDestinationController.getAbsoluteDestinationPath('abc', undefined, 'zrc')).to.equal('abc/zrc'); 104 | }); 105 | 106 | it("Should return 'efg/zrc' when outDir is 'abc' and writeMediaTo is 'efg'", () => { 107 | expect(MediaDestinationController.getAbsoluteDestinationPath('abc', 'efg', 'zrc')).to.equal('efg/zrc'); 108 | }); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /test/mocking_utils.ts: -------------------------------------------------------------------------------- 1 | import { GetBlockResponse, GetDatabaseResponse, GetPageResponse } from '@notionhq/client/build/src/api-endpoints'; 2 | import { NotionDatabaseAssociation, Token } from '../src/models/app_models'; 3 | import { 4 | Context, 5 | DatabaseRule, 6 | NotionBlock, 7 | NotionDatabase, 8 | NotionPage, 9 | SortsParams, 10 | TemplateRule, 11 | } from '../src/models/config_models'; 12 | import { 13 | BlockChildrenFetcher, 14 | CachingFileReader, 15 | DatabaseAssociationFinder, 16 | DatabaseFetcher, 17 | FileExtensionFinder, 18 | SecondaryDatabasesFetcher, 19 | TemplateRuleOutputWriter, 20 | } from '../src/services/build_service'; 21 | import FileSystemService from '../src/services/file_system_service'; 22 | import MediaService from '../src/services/media_service'; 23 | import MustacheService from '../src/services/mustache_service'; 24 | import NotionService from '../src/services/notion_service'; 25 | import PostProcessingService from '../src/services/post_processing_service'; 26 | import FileNameService from '../src/services/file_name_service'; 27 | 28 | export function setMockedFileSystemService(mock: { 29 | readFileAsString?: (path: string) => string; 30 | writeStringToFile?: (data: string, path: string) => void; 31 | doesFolderExist?: (path: string) => boolean; 32 | createFolder?: (path: string) => void; 33 | }) { 34 | FileSystemService.setMockedInstance(mock as FileSystemService); 35 | } 36 | 37 | export function setMockedMustacheService(mock: { render: (view: object, template: string) => string }) { 38 | MustacheService.setMockedInstance(mock); 39 | } 40 | 41 | export function setMockedNotionService(mock: { 42 | getDatabase?: (args: { withId: string; withToken: Token }) => Promise; 43 | queryForDatabasePages?: (args: { 44 | databaseId: string; 45 | withToken: Token; 46 | takeOnly?: number; 47 | sort?: SortsParams; 48 | filter?: object; 49 | }) => AsyncGenerator; 50 | getPageBlocks?: (args: { pageId: string; withToken: Token }) => AsyncGenerator; 51 | getBlockChildren?: (args: { blockId: string; withToken: Token }) => AsyncGenerator; 52 | }) { 53 | NotionService.setMockedInstance(mock as NotionService); 54 | } 55 | 56 | export function setMockedMediaService(mock: { 57 | stageFetchRequest?: (url: string, templateRule: TemplateRule) => string; 58 | commit?: () => Promise; 59 | }) { 60 | MediaService.setMockedInstance(mock as MediaService); 61 | } 62 | 63 | export function setMockedPostProcessingService(mock: { 64 | submit?: (content: string, pgPage: string, pgId: string) => void; 65 | flush?: () => void; 66 | }) { 67 | PostProcessingService.setMockedInstance(mock as PostProcessingService); 68 | } 69 | 70 | export function setMockedFileNameService(mock: { genUnique: () => string }) { 71 | FileNameService.setMockedInstance(mock); 72 | } 73 | 74 | export function asMockedFileExtensionFinder(mock: { findFileExtensionOf: (path: string) => string }) { 75 | return mock as FileExtensionFinder; 76 | } 77 | 78 | export function asMockedDatabaseAssociationFinder(mock: { 79 | findDatabaseAssociationFor: ( 80 | rule: DatabaseRule, 81 | databaseAssociations: NotionDatabaseAssociation[], 82 | ) => NotionDatabaseAssociation; 83 | }) { 84 | return mock as DatabaseAssociationFinder; 85 | } 86 | 87 | export function asMockedCachingFileReader(mock: { readAsString: (path: string) => string }) { 88 | return mock as CachingFileReader; 89 | } 90 | 91 | export function asMockedBlockChildrenFetcher(mock: { 92 | fetchChildren: (blockId: string, notionToken: Token) => Promise; 93 | }) { 94 | return mock as BlockChildrenFetcher; 95 | } 96 | 97 | export function asMockedDatabaseFetcher(mock: { 98 | fetchDatabase: (args: { 99 | databaseRule: DatabaseRule; 100 | association: NotionDatabaseAssociation; 101 | context: Context; 102 | onPostPageMapping: (notionPage: NotionPage) => Promise; 103 | }) => Promise; 104 | }) { 105 | return mock as DatabaseFetcher; 106 | } 107 | 108 | export function asMockedSecondaryDatabaseFetcher(mock: { 109 | fetchAll: ( 110 | databaseRules: DatabaseRule[], 111 | databaseAssociations: NotionDatabaseAssociation[], 112 | ctx: Context, 113 | ) => Promise; 114 | }) { 115 | return mock as SecondaryDatabasesFetcher; 116 | } 117 | 118 | export function asMockedTemplateRuleOutputWriter(mock: { 119 | write: (page: NotionPage, pageTemplateRule: TemplateRule) => Promise; 120 | }) { 121 | return mock as TemplateRuleOutputWriter; 122 | } 123 | -------------------------------------------------------------------------------- /test/notion_service.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { expect } from 'chai'; 3 | import { APIErrorCode } from '@notionhq/client/build/src'; 4 | import { resultOf } from '../src/services/notion_service'; 5 | 6 | describe('service_utils tests', () => { 7 | describe('_resultOf', () => { 8 | it('Should return the value returned by the passed closure', async () => { 9 | const promise = new Promise((resolve, _) => resolve('abc')); 10 | expect(await resultOf(() => promise)).to.equal('abc'); 11 | }); 12 | 13 | describe('When the passed closure throws Notion API rate limit error', () => { 14 | it('should sleep for 333 milliseconds', async () => { 15 | const before = Date.now(); 16 | let count = 0; 17 | 18 | await resultOf(() => { 19 | count++; 20 | return new Promise((resolve, reject) => { 21 | if (count < 2) reject({ code: APIErrorCode.RateLimited }); 22 | else resolve('abc'); 23 | }); 24 | }); 25 | 26 | const diff = Date.now() - before; 27 | expect(diff).to.be.greaterThanOrEqual(333).but.lessThan(400); 28 | }); 29 | 30 | it('Should return the value the passed closure eventually returns', async () => { 31 | let count = 0; 32 | 33 | const ret = await resultOf(() => { 34 | count++; 35 | return new Promise((resolve, reject) => { 36 | if (count < 2) reject({ code: APIErrorCode.RateLimited }); 37 | else resolve('abc'); 38 | }); 39 | }); 40 | 41 | expect(ret).to.equal('abc'); 42 | }); 43 | }); 44 | 45 | describe('When the passed closure throws an error that is NOT a Notion API rate limit error', () => { 46 | it('Should rethrow the error', async () => { 47 | let thrownError: any; 48 | 49 | try { 50 | await resultOf(() => { 51 | return new Promise((_, reject) => { 52 | reject('abc'); 53 | }); 54 | }); 55 | } catch (e) { 56 | thrownError = e; 57 | } 58 | 59 | expect(thrownError).to.equal('abc'); 60 | }); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /test/post_processing_service.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { expect } from 'chai'; 3 | import PostProcessingService from '../src/services/post_processing_service'; 4 | import { setMockedFileSystemService } from './mocking_utils'; 5 | 6 | // We get an error when we use import. 7 | // This is the work around for now. 8 | const deepEqualInAnyOrder = require('deep-equal-in-any-order'); 9 | const chai = require('chai'); 10 | 11 | declare type InPage = { 12 | path: string; 13 | contents: string; 14 | id: string; 15 | }; 16 | 17 | declare type OutPage = { 18 | path: string; 19 | contents: string; 20 | }; 21 | 22 | chai.use(deepEqualInAnyOrder); 23 | 24 | describe('PostProcessingService tests', () => { 25 | describe('submit then flush', () => { 26 | function testSubmitAndFlush(args: { inPages: InPage[]; expectedOutPages: OutPage[] }): void { 27 | let observedOutPages: OutPage[] = []; 28 | 29 | setMockedFileSystemService({ 30 | writeStringToFile: (data, path) => { 31 | observedOutPages.push({ 32 | path: path, 33 | contents: data, 34 | }); 35 | }, 36 | }); 37 | 38 | const instance = new PostProcessingService(); 39 | args.inPages.forEach((page) => instance.submit(page.contents, page.path, page.id)); 40 | instance.flush(); 41 | 42 | expect(observedOutPages).to.deep.equalInAnyOrder(args.expectedOutPages); 43 | } 44 | 45 | it('Should look ahead (i.e page A refrences page B)', () => { 46 | testSubmitAndFlush({ 47 | inPages: [ 48 | { 49 | path: 'pageA', 50 | contents: 'Link to B is :::pathTo:::abc:::', 51 | id: 'page-A-id', 52 | }, 53 | 54 | { 55 | path: 'pageB', 56 | contents: 'This is Page B', 57 | id: 'abc', 58 | }, 59 | ], 60 | expectedOutPages: [ 61 | { 62 | path: 'pageA', 63 | contents: 'Link to B is pageB', 64 | }, 65 | 66 | { 67 | path: 'pageB', 68 | contents: 'This is Page B', 69 | }, 70 | ], 71 | }); 72 | }); 73 | 74 | it('Should look behind (i.e page B refrences page A)', () => { 75 | testSubmitAndFlush({ 76 | inPages: [ 77 | { 78 | path: 'pageA', 79 | contents: 'This is Page A', 80 | id: 'efg', 81 | }, 82 | 83 | { 84 | path: 'pageB', 85 | contents: 'Link to A is :::pathTo:::efg:::', 86 | id: 'page-B-id', 87 | }, 88 | ], 89 | expectedOutPages: [ 90 | { 91 | path: 'pageA', 92 | contents: 'This is Page A', 93 | }, 94 | 95 | { 96 | path: 'pageB', 97 | contents: 'Link to A is pageA', 98 | }, 99 | ], 100 | }); 101 | }); 102 | 103 | it('Should look ahead and behind (i.e page C references page D and page B)', () => { 104 | testSubmitAndFlush({ 105 | inPages: [ 106 | { 107 | path: 'pageB', 108 | contents: 'This is Page B', 109 | id: 'abc', 110 | }, 111 | 112 | { 113 | path: 'pageC', 114 | contents: 'Link to B is :::pathTo:::abc::: and link to D is :::pathTo:::efg:::', 115 | id: 'page-C-id', 116 | }, 117 | 118 | { 119 | path: 'pageD', 120 | contents: 'This is Page D', 121 | id: 'efg', 122 | }, 123 | ], 124 | expectedOutPages: [ 125 | { 126 | path: 'pageB', 127 | contents: 'This is Page B', 128 | }, 129 | 130 | { 131 | path: 'pageC', 132 | contents: 'Link to B is pageB and link to D is pageD', 133 | }, 134 | 135 | { 136 | path: 'pageD', 137 | contents: 'This is Page D', 138 | }, 139 | ], 140 | }); 141 | }); 142 | 143 | it('Should flush pages as they are when they do not have any references (i.e page D does not reference any other page)', () => { 144 | testSubmitAndFlush({ 145 | inPages: [ 146 | { 147 | path: 'pageD', 148 | contents: 'This is Page D', 149 | id: 'page-D-id', 150 | }, 151 | ], 152 | expectedOutPages: [ 153 | { 154 | path: 'pageD', 155 | contents: 'This is Page D', 156 | }, 157 | ], 158 | }); 159 | }); 160 | 161 | it('Should flush pages when their references never get resolved (i.e page E references page F, which is never submitted)', () => { 162 | testSubmitAndFlush({ 163 | inPages: [ 164 | { 165 | path: 'pageE', 166 | contents: 'Link to page F is :::pathTo:::page-F-id:::', 167 | id: 'page-E-id', 168 | }, 169 | ], 170 | expectedOutPages: [ 171 | { 172 | path: 'pageE', 173 | contents: 'Link to page F is :::pathTo:::page-F-id:::', 174 | }, 175 | ], 176 | }); 177 | }); 178 | 179 | describe('Resolving page paths to relative links', () => { 180 | function testResolvesPathsToRelativeLinks(args: { referenceTo: string; in: string; expectedLink: string }) { 181 | testSubmitAndFlush({ 182 | inPages: [ 183 | { 184 | path: args.in, 185 | contents: 'Link to page B is :::pathTo:::page-B-id:::', 186 | id: 'page-A-id', 187 | }, 188 | 189 | { 190 | path: args.referenceTo, 191 | contents: 'This is page B', 192 | id: 'page-B-id', 193 | }, 194 | ], 195 | expectedOutPages: [ 196 | { 197 | path: args.in, 198 | contents: `Link to page B is ${args.expectedLink}`, 199 | }, 200 | 201 | { 202 | path: args.referenceTo, 203 | contents: 'This is page B', 204 | }, 205 | ], 206 | }); 207 | } 208 | 209 | it("Should resolve reference to './pageB' in './pageA' to './pageB'", () => { 210 | testResolvesPathsToRelativeLinks({ 211 | referenceTo: './pageB', 212 | in: './pageA', 213 | expectedLink: './pageB', 214 | }); 215 | }); 216 | 217 | it("Should resolve reference to 'pageB' in 'pageA' to 'pageB'", () => { 218 | testResolvesPathsToRelativeLinks({ 219 | referenceTo: 'pageB', 220 | in: 'pageA', 221 | expectedLink: 'pageB', 222 | }); 223 | }); 224 | 225 | it("Should resolve reference to 'public/pageB' in 'public/pageA' to 'pageB'", () => { 226 | testResolvesPathsToRelativeLinks({ 227 | referenceTo: 'public/pageB', 228 | in: 'public/pageA', 229 | expectedLink: 'pageB', 230 | }); 231 | }); 232 | 233 | it("Should resolve reference to 'public/pages/pageB' in 'public/pageA' to 'pages/pageB'", () => { 234 | testResolvesPathsToRelativeLinks({ 235 | referenceTo: 'public/pages/pageB', 236 | in: 'public/pageA', 237 | expectedLink: 'pages/pageB', 238 | }); 239 | }); 240 | 241 | it("Should resolve reference to 'public/pageB' in 'public/pages/pageA' to '../pageB'", () => { 242 | testResolvesPathsToRelativeLinks({ 243 | referenceTo: 'public/pageB', 244 | in: 'public/pages/pageA', 245 | expectedLink: '../pageB', 246 | }); 247 | }); 248 | 249 | it("Should resolve reference to 'public/pageB' in 'others/pageA' to 'public/pageB'", () => { 250 | testResolvesPathsToRelativeLinks({ 251 | referenceTo: 'public/pageB', 252 | in: 'others/pageA', 253 | expectedLink: 'public/pageB', 254 | }); 255 | }); 256 | }); 257 | 258 | describe('URL encoding links', () => { 259 | it("Should encode relative page path './page 2\".md' to './page%202%22.md'", () => { 260 | testSubmitAndFlush({ 261 | inPages: [ 262 | { 263 | path: './page 2".md', 264 | contents: 'This is Page A', 265 | id: 'efg', 266 | }, 267 | 268 | { 269 | path: 'pageB', 270 | contents: 'Link to A is :::pathTo:::efg:::', 271 | id: 'page-B-id', 272 | }, 273 | ], 274 | expectedOutPages: [ 275 | { 276 | path: './page 2".md', 277 | contents: 'This is Page A', 278 | }, 279 | 280 | { 281 | path: 'pageB', 282 | contents: 'Link to A is ./page%202%22.md', 283 | }, 284 | ], 285 | }); 286 | }); 287 | }); 288 | }); 289 | }); 290 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import { setMockedLogger } from '../src/logger'; 2 | 3 | // Disable logging 4 | setMockedLogger({ 5 | logPageFlushed: (_) => undefined, 6 | logWithColor: (_) => undefined, 7 | }); 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "noImplicitReturns": true, 5 | "noUnusedLocals": true, 6 | "outDir": "lib", 7 | "sourceMap": true, 8 | "strict": true, 9 | "target": "es2017", 10 | "moduleResolution": "node", 11 | "declaration": true, 12 | "esModuleInterop": true 13 | }, 14 | "compileOnSave": true, 15 | "include": ["src"] 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.testing.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "noImplicitReturns": true, 5 | "noUnusedLocals": true, 6 | "outDir": "lib", 7 | "sourceMap": true, 8 | "strict": true, 9 | "target": "es2017", 10 | "moduleResolution": "node", 11 | "declaration": true, 12 | "esModuleInterop": true 13 | }, 14 | "compileOnSave": true, 15 | "include": ["src"] 16 | } 17 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-config-prettier"], 3 | "rules": { 4 | "no-console": false, 5 | "class-name": [true, "allow-leading-underscore"], 6 | "max-classes-per-file": [true, 100, "exclude-class-expressions"], 7 | "array-type": false 8 | } 9 | } 10 | --------------------------------------------------------------------------------