├── .cursor └── rules │ └── notion-api.mdc ├── .github └── workflows │ ├── create-daily-wordle-game.yml │ └── sync-oura-data.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── .vscode └── settings.json ├── README.md ├── examples ├── architect-os │ ├── config.js │ └── update-dates.js ├── databases │ ├── bulk-edit │ │ ├── changelog.js │ │ ├── index.js │ │ └── spends-and-trends.js │ ├── create │ │ ├── from-schema.js │ │ └── index.js │ ├── retrieve │ │ └── index.js │ ├── search │ │ └── index.js │ ├── sort-multi-select │ │ └── index.js │ └── update │ │ └── index.js ├── files │ ├── data │ │ ├── example.mp4 │ │ ├── example.png │ │ └── example.txt │ ├── upload-cover.js │ ├── upload-file.js │ └── upload-multipart.js ├── formula1 │ ├── circuits.js │ └── shared.js ├── nm │ ├── align-member-ids.js │ ├── align-user-ids.js │ ├── bulk-member-add-by-email.js │ ├── bulk-member-add.js │ ├── bulk-member-remove.js │ ├── change-member-emails.js │ ├── data │ │ ├── .gitignore │ │ ├── groups.json │ │ ├── scim-group-add.json │ │ └── scim-user-add.json │ ├── list-groups.js │ ├── list-legacy.js │ ├── list-members.js │ ├── member-add-all.js │ ├── member-add.js │ ├── member-remove.js │ ├── shared.js │ ├── split-group-datas.js │ └── user-remove.js ├── oura │ ├── oura.js │ └── sync.js ├── pages │ ├── append-to-page │ │ └── index.js │ ├── create-from-template │ │ └── index.js │ ├── create-in-database │ │ └── index.js │ ├── create-many │ │ └── index.js │ ├── create-with-external-icon │ │ └── index.js │ ├── create │ │ └── index.js │ └── retrieve │ │ └── index.js ├── people │ ├── list │ │ └── index.js │ ├── me │ │ └── index.js │ └── retrieve │ │ └── index.js ├── properties │ ├── add-advanced.js │ ├── add-property.js │ ├── google-drive-property.js │ └── retrieve.js ├── scim │ ├── groups │ │ ├── create.js │ │ ├── delete.js │ │ ├── get.js │ │ ├── list.js │ │ ├── member-add.js │ │ ├── member-remove.js │ │ ├── schema.js │ │ └── update.js │ ├── service-provider.js │ └── users │ │ ├── create.js │ │ ├── delete.js │ │ ├── get.js │ │ ├── list.js │ │ ├── schema.js │ │ ├── update-patch.js │ │ └── update-put.js ├── search │ └── index.js ├── shared │ ├── circle.js │ ├── convertkit.js │ ├── create-from-template.js │ ├── fetch-pages.js │ ├── files.js │ ├── index.js │ ├── props.js │ ├── scim.js │ ├── titled-date.js │ └── utils.js └── wordle │ └── create-game │ ├── index.js │ └── words.js ├── package-lock.json └── package.json /.cursor/rules/notion-api.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | - This repository contains a series of API examples using Notion's official public JavaScript client found at https://github.com/makenotion/notion-sdk-js. 7 | - We'll aim to cover most of the endpoints, including some API endpoints that are enterprise only (namely the SCIM API, examples of which are found in examples/scim). 8 | - You'll use JavaScript and attempt to extract reusable components for accessing the APIs to the folder. 9 | -------------------------------------------------------------------------------- /.github/workflows/create-daily-wordle-game.yml: -------------------------------------------------------------------------------- 1 | name: Create Daily Wordle Game 2 | 3 | on: 4 | push: 5 | branches: 6 | - create-wordle-game-action 7 | # schedule: 8 | # 08:05 UTC is just after midnight in Pacfic Time 9 | # - cron: '5 8 * * *' 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - uses: actions/setup-node@v2 19 | with: 20 | node-version: lts/erbium 21 | 22 | - uses: actions/cache@v2 23 | with: 24 | path: ~/.npm 25 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 26 | restore-keys: | 27 | ${{ runner.os }}-node- 28 | 29 | - name: Install dependencies 30 | run: npm install 31 | 32 | - name: Create Wordle game board 33 | env: 34 | NOTION_API_TOKEN: ${{ secrets.NOTION_API_TOKEN }} 35 | WORDLE_GAMES_DB_ID: ${{ secrets.WORDLE_GAMES_DB_ID }} 36 | WORDLE_WORDS_DB_ID: ${{ secrets.WORDLE_WORDS_DB_ID }} 37 | run: | 38 | node examples/wordle/create-game/index.js \ 39 | --games-db-id=${WORDLE_GAMES_DB_ID} \ 40 | --words-db-id=${WORDLE_WORDS_DB_ID} 41 | -------------------------------------------------------------------------------- /.github/workflows/sync-oura-data.yml: -------------------------------------------------------------------------------- 1 | name: Sync Oura Data 2 | on: 3 | push: 4 | branches: 5 | - sync-oura-data 6 | schedule: 7 | # 06:00 UTC is 23:00 Pacific Time 8 | - cron: '0 6 * * *' 9 | jobs: 10 | sync: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: lts/hydrogen 17 | - uses: actions/cache@v4 18 | with: 19 | path: ~/.npm 20 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 21 | restore-keys: | 22 | ${{ runner.os }}-node- 23 | - name: Install dependencies 24 | run: npm install 25 | - name: Sync Oura data to Notion 26 | env: 27 | NOTION_API_TOKEN: ${{ secrets.OURA_NOTION_API_TOKEN }} 28 | OURA_JOURNAL_DATABASE_ID: ${{ secrets.OURA_JOURNAL_DATABASE_ID }} 29 | OURA_RING_TOKEN: ${{ secrets.OURA_RING_TOKEN }} 30 | run: | 31 | node examples/oura/sync.js 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.env 2 | /node_modules 3 | **/tmp 4 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/hydrogen 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /.env 2 | /node_modules 3 | **/tmp 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "proseWrap": "preserve", 4 | "singleQuote": true, 5 | "vueIndentScriptAndStyle": true 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "files.exclude": { 4 | "**/tmp": false 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notion API Examples 2 | 3 | This is a Notion API playground. 4 | 5 | You can find some Notion API examples in the aptly named "examples" directory. 6 | 7 | ## Set up 8 | 9 | If you're using nvm, you can `nvm install` and then `nvm use`, otherwise, you will need to [install node](https://nodejs.org/en/download/) to run these example scripts. If you're not sure, download the LTS version for your platform. 10 | 11 | Once you've done this, make sure the following command outputs the node version when run inside this project's directory: 12 | 13 | ``` 14 | node --version 15 | ``` 16 | 17 | From the root directory of this project, run: 18 | 19 | ``` 20 | npm install 21 | ``` 22 | 23 | Create a _.env_ file in the root directory of this project and add your Notion API Token there (after the `=` in the example below). 24 | 25 | ``` 26 | NOTION_API_TOKEN=secret_abc123 27 | ``` 28 | 29 | Now all the scripts in the examples folder will use your token. 30 | 31 | **Be very careful not to commit your Notion API token to the repository if you create a fork of this repository.** 32 | 33 | ## Running Examples 34 | 35 | In general, you will select a file in the _examples_ directory and run it with `node`: 36 | 37 | ``` 38 | node examples/databases/sort-multi-select/index.js 39 | ``` 40 | 41 | Most scripts have parameters that can be passed via the command line. The defaults of these are all using _my_ stuff, so you will either have to change them in the code, or use the command line arguments. 42 | 43 | Example: 44 | 45 | ``` 46 | node examples/databases/sort-multi-select/index.js --database-id=DATABASE_ID --sort-prop=PROPERTY_NAME --no-case-sensitive 47 | ``` 48 | 49 | ### Parameters 50 | 51 | I will add comments to the top of each script indicating parameters that can be specified (WIP), but the following are always available to all scripts: 52 | 53 | - `--notion-api-token` overrides the var `NOTION_API_TOKEN` if specified in .env file. Must be provided for all scripts if not set in .env file. 54 | 55 | ## Caveats 56 | 57 | There is very little error handling in these examples. Mostly because I want to fail hard and see the errors. So you will see very few `try...catch` statements throughout. You will want to handle errors gracefully in real-world scenarios. 58 | -------------------------------------------------------------------------------- /examples/architect-os/config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | dashboards: { 3 | hq: { 4 | title: 'HQ', 5 | source: 'a0386c317e09461cb70902ac46d590ff', 6 | }, 7 | }, 8 | databases: { 9 | calendar: { 10 | title: 'Calendar', 11 | id: '37abd6a829a64606a1809fc43e8a31d9', 12 | date: { property: 'Date' }, 13 | }, 14 | docs: { title: 'Docs', id: '70c5ecb429b44e8982bd1838a2d3ba4e' }, 15 | locations: { title: 'Locations', id: '55689b6a9cb54555b39241f85d744584' }, 16 | people: { title: 'People', id: 'df0959f7f8e947ac9d019ac2f8992df6' }, 17 | projects: { 18 | title: 'Projects', 19 | id: '5bdcff6258b246788536d5c642b2fe55', 20 | date: { 21 | property: 'Date(s)', 22 | range: 30, 23 | overlap: 10, 24 | filter: { property: 'Status', status: { does_not_equal: 'Done' } }, 25 | }, 26 | }, 27 | resources: { title: 'Resources', id: '5e6a4a95edd544388cf0bf8521175b3b' }, 28 | tasks: { 29 | title: 'Tasks', 30 | id: '7da00e0d214448a4ba517f68eb6fee61', 31 | date: { 32 | property: 'Date', 33 | filter: { property: 'Status', status: { does_not_equal: 'Done' } }, 34 | }, 35 | }, 36 | teams: { title: 'Teams', id: '7342bb3680aa431a85759ccc0eb94171' }, 37 | tensions: { title: 'Tensions', id: '566dcc1ac06440d3a0f00a1343b5e215' }, 38 | updates: { title: 'Updates', id: '27cf7cf442aa46388a1f9769d448d57f' }, 39 | }, 40 | }; 41 | 42 | module.exports = config; 43 | -------------------------------------------------------------------------------- /examples/architect-os/update-dates.js: -------------------------------------------------------------------------------- 1 | const { RateLimit } = require('async-sema'); 2 | const { addDays, format } = require('date-fns'); 3 | const { notion } = require('../shared'); 4 | const { fetchAllPages } = require('../shared/fetch-pages'); 5 | const config = require('./config'); 6 | 7 | const { databases } = config; 8 | 9 | const limit = RateLimit(1, { 10 | timeUnit: 2000, 11 | uniformDistribution: true, 12 | }); 13 | 14 | const ISO_FORMAT = 'yyyy-MM-dd'; 15 | 16 | async function updateDates(page, dateProp, startDate, endDate = null) { 17 | const dateValue = format(startDate, ISO_FORMAT); 18 | let dateString = dateValue; 19 | const date = { 20 | start: dateValue, 21 | time_zone: null, 22 | }; 23 | 24 | if (endDate) { 25 | const endDateValue = format(endDate, ISO_FORMAT); 26 | dateString = `${dateString}—${endDateValue}`; 27 | 28 | date.end = endDateValue; 29 | } 30 | 31 | const properties = { 32 | [dateProp]: { 33 | date, 34 | }, 35 | }; 36 | 37 | const title = page.properties.Name.title[0].plain_text; 38 | console.log(`\t"${title}" -> ${dateString}`); 39 | 40 | return await notion.pages.update({ 41 | page_id: page.id, 42 | properties, 43 | }); 44 | } 45 | 46 | function getDate(page, prop = 'Date') { 47 | let { 48 | [prop]: { 49 | date: { start: date }, 50 | }, 51 | } = page.properties; 52 | 53 | return new Date(date); 54 | } 55 | 56 | function validDateConfig(part, defaultValue = 1) { 57 | if (typeof part === 'number' && !isNaN(part) && isFinite(part)) { 58 | return part; 59 | } 60 | return defaultValue; 61 | } 62 | 63 | (async function () { 64 | const entries = Object.entries(databases).filter(([, db]) => 'date' in db); 65 | 66 | for (const [key, conf] of entries) { 67 | let { 68 | id, 69 | date: { property: dateProp, range, overlap, filter }, 70 | title: dbTitle, 71 | } = conf; 72 | 73 | console.log(`Updating ${dbTitle} dates (${id})...`); 74 | 75 | const database = await notion.databases.retrieve({ database_id: id }); 76 | const query = filter ? { filter } : null; 77 | const pages = await fetchAllPages(id, query); 78 | 79 | const sortedPages = pages.sort((a, b) => { 80 | return getDate(a, dateProp) - getDate(b, dateProp); 81 | }); 82 | 83 | range = validDateConfig(range, 1); 84 | overlap = validDateConfig(overlap, 0); 85 | 86 | let increment = range - overlap; 87 | let counter = 0; 88 | 89 | for (const page of sortedPages) { 90 | await limit(); 91 | 92 | let startDate = addDays(new Date(), counter); 93 | let endDate = increment > 1 ? addDays(startDate, range) : null; 94 | 95 | await updateDates(page, dateProp, startDate, endDate); 96 | 97 | counter += increment; 98 | } 99 | } 100 | })(); 101 | -------------------------------------------------------------------------------- /examples/databases/bulk-edit/changelog.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Arguments: 3 | * 4 | * --database-id: ID of the database to create in 5 | */ 6 | 7 | const _ = require('lodash'); 8 | const { notion, yargs } = require('../../shared'); 9 | const { fetchAllPages, performWithAll } = require('../../shared/fetch-pages'); 10 | const { emoji } = require('../../shared/props'); 11 | const { log } = require('../../shared/utils'); 12 | 13 | const databaseId = 'e8a07b67d28a432fbd9029f18f3a27b7'; 14 | const argv = yargs.default({ databaseId }).argv; 15 | 16 | async function editPage(page) { 17 | process.stdout.write('.'); 18 | 19 | let { 20 | Lesson: { title }, 21 | Date: { date }, 22 | } = page.properties; 23 | 24 | const [dateMentions, otherTitles] = _.partition( 25 | title, 26 | (part) => part.type === 'mention' && part.mention.type === 'date' 27 | ); 28 | 29 | if (!(date || _.isEmpty(dateMentions))) { 30 | [{ mention: date }] = dateMentions; 31 | } else { 32 | date = { date }; 33 | } 34 | 35 | const lessonTitles = _.reject(otherTitles, ({ type, plain_text: plainText }) => { 36 | return type === 'text' && _.isEmpty(plainText.replace(/\s/g, '')); 37 | }); 38 | 39 | const properties = { Lesson: { title: lessonTitles } }; 40 | if (date) { 41 | properties.Date = date; 42 | } 43 | 44 | const args = { 45 | page_id: page.id, 46 | icon: emoji('🆕'), 47 | properties, 48 | }; 49 | 50 | return await notion.pages.update(args); 51 | } 52 | 53 | (async () => { 54 | const pages = await fetchAllPages(databaseId); 55 | 56 | await performWithAll(pages, editPage); 57 | })(); 58 | -------------------------------------------------------------------------------- /examples/databases/bulk-edit/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This example was with an imported csv file that had each item's title prefixed 3 | * with "Image - " or "Video -". In this example, we remove the prefix, re-title 4 | * the Page, and apply the corresponding "Image" or "Video" as the Page's "Type" 5 | * property which is a Select field. 6 | */ 7 | 8 | /** 9 | * Arguments: 10 | * 11 | * --database-id: ID of the database to edit pages in 12 | */ 13 | 14 | const { notion, yargs } = require('../../shared'); 15 | const { fetchAllPages, performWithAll } = require('../../shared/fetch-pages'); 16 | 17 | const databaseId = '9d550c3fe4cf4b4bac9343fba0f4aa56'; 18 | const argv = yargs.default({ databaseId }).argv; 19 | 20 | const expr = /(Image|Video) - /; 21 | 22 | async function editPage(page) { 23 | process.stdout.write('.'); 24 | 25 | // Get the current page title 26 | let { 27 | Name: { 28 | title: [{ plain_text: title }], 29 | }, 30 | } = page.properties; 31 | 32 | // If the page's title does not match, no need to update 33 | if (!expr.test(title)) { 34 | return Promise.resolve(); 35 | } 36 | 37 | // Capture the asset type from the title 38 | const [, type] = expr.exec(title); 39 | 40 | // Remove the prefix 41 | title = title.replace(expr, ''); 42 | 43 | // Finally update the page with the new title 44 | return await notion.pages.update({ 45 | page_id: page.id, 46 | properties: { 47 | Name: { 48 | title: [ 49 | { 50 | type: 'text', 51 | text: { 52 | content: title, 53 | }, 54 | }, 55 | ], 56 | }, 57 | Type: { 58 | select: { 59 | name: type, 60 | }, 61 | }, 62 | }, 63 | }); 64 | } 65 | 66 | (async () => { 67 | const pages = await fetchAllPages(argv.databaseId, { 68 | filter: { 69 | or: [ 70 | { 71 | property: 'Name', 72 | rich_text: { 73 | starts_with: 'Image - ', 74 | }, 75 | }, 76 | { 77 | property: 'Name', 78 | rich_text: { 79 | starts_with: 'Video - ', 80 | }, 81 | }, 82 | ], 83 | }, 84 | }); 85 | 86 | await performWithAll(pages, editPage); 87 | })(); 88 | -------------------------------------------------------------------------------- /examples/databases/bulk-edit/spends-and-trends.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Example bulk-edit script. In this script, there are two databases. One is 3 | * Spends which has a bunch of data for advertising spends in it. The other 4 | * is Trends, which has a bunch of data for traffic/metrics for websites. There 5 | * is a relation between the two. 6 | * 7 | * This script is updating all pages in the Trends database by: 8 | * 9 | * 1. Apply an icon to each page. 10 | * 2. Find the corresponding Spends relation (by ID) and relating it to the Trends entry with the same date. 11 | * 3. Re-titles the Trend page using the Date property. 12 | */ 13 | 14 | const { format, parse } = require('date-fns'); 15 | const { notion } = require('../../shared'); 16 | const { fetchAllPages, performWithAll } = require('../../shared/fetch-pages'); 17 | const { emoji } = require('../../shared/props'); 18 | 19 | const spendsDbId = '029a7e3e6b734169a0e973493fc0dbf1'; 20 | const trendsDbId = 'e7cfab7d65394f17904b7123ec3bfe1f'; 21 | 22 | async function editPage(page) { 23 | process.stdout.write('.'); 24 | 25 | let { 26 | Date: { 27 | date: { start: actualDate }, 28 | }, 29 | } = page.properties; 30 | 31 | const parsed = parse(actualDate, 'yyyy-MM-dd', new Date()); 32 | const dateTitle = format(parsed, 'MMM d, yyyy'); 33 | const spendsTitle = `Spends: ${dateTitle}`; 34 | 35 | const { 36 | results: [spends], 37 | } = await notion.databases.query({ 38 | database_id: spendsDbId, 39 | filter: { 40 | property: 'ID', 41 | title: { 42 | equals: spendsTitle, 43 | }, 44 | }, 45 | }); 46 | 47 | if (!spends) { 48 | throw new Error(`"${spendsTitle}" could not be found in the Spends database!`); 49 | } 50 | 51 | return await notion.pages.update({ 52 | page_id: page.id, 53 | icon: emoji('📈'), 54 | properties: { 55 | ID: { 56 | title: [ 57 | { 58 | type: 'text', 59 | text: { 60 | content: `Trends: ${dateTitle}`, 61 | }, 62 | }, 63 | ], 64 | }, 65 | Spends: { 66 | relation: [ 67 | { 68 | id: spends.id, 69 | }, 70 | ], 71 | }, 72 | }, 73 | }); 74 | } 75 | 76 | (async () => { 77 | const pages = await fetchAllPages(trendsDbId, { 78 | sorts: [ 79 | { 80 | property: 'Date', 81 | direction: 'ascending', 82 | }, 83 | ], 84 | }); 85 | 86 | await performWithAll(pages, editPage); 87 | })(); 88 | -------------------------------------------------------------------------------- /examples/databases/create/from-schema.js: -------------------------------------------------------------------------------- 1 | /** 2 | * "Duplicate" a database without content. 3 | * 4 | * See comments in code for explanation and limitations. 5 | * 6 | * Arguments: 7 | * 8 | * --database-id: ID of the database to duplicate 9 | * --parent-id: ID of page to add database to 10 | * --title: Title of the database to create (defaults to " (Copy)") 11 | */ 12 | 13 | const { notion, yargs } = require('../../shared'); 14 | const { getPropertySchema, text } = require('../../shared/props'); 15 | const { log } = require('../../shared/utils'); 16 | 17 | // The database we are duplicating and the page to duplicate it into 18 | const databaseId = 'b4e8a119a37342e099aa452274f78a70'; 19 | const parentId = '3925de9e4cb9446197bc45032125320c'; 20 | const argv = yargs.default({ databaseId, parentId }).argv; 21 | 22 | (async () => { 23 | // Fetch the database we want to duplicate 24 | let { icon, properties, title } = await notion.databases.retrieve({ 25 | database_id: argv.databaseId, 26 | }); 27 | 28 | // Use title if supplied, otherwise... 29 | if (argv.title) { 30 | title = [text(argv.title)]; 31 | } else { 32 | // ...add " (Copy)" to the new database title. 33 | title.push({ 34 | type: 'text', 35 | text: { 36 | content: ' (Copy)', 37 | }, 38 | }); 39 | } 40 | 41 | // Using the existing database's properties, remove extraneous keys 42 | // and properties not yet supported by the API (status). 43 | properties = getPropertySchema(properties); 44 | 45 | // Prep database params, including copying over the icon 46 | const params = { 47 | icon, 48 | parent: { 49 | type: 'page_id', 50 | page_id: argv.parentId, 51 | }, 52 | properties, 53 | title, 54 | }; 55 | 56 | // Create the database 57 | const database = await notion.databases.create(params); 58 | 59 | log(database); 60 | })(); 61 | -------------------------------------------------------------------------------- /examples/databases/create/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Create a database. 3 | * 4 | * Simple example to test creating Formulas via the API. 5 | * 6 | * Arguments: 7 | * 8 | * --parent-id: ID of page to add database to 9 | * --title: Title of the database to change to 10 | */ 11 | 12 | const { notion, yargs } = require('../../shared'); 13 | const props = require('../../shared/props'); 14 | const { log } = require('../../shared/utils'); 15 | 16 | const title = 'Database Name'; 17 | const parentId = '6ee29653d0bf418e8f68b4b9a5a81d88'; 18 | const argv = yargs.default({ parentId, title }).argv; 19 | 20 | const properties = { 21 | Name: { title: {} }, 22 | X: { number: {} }, 23 | Y: { number: {} }, 24 | 'X + Y': { 25 | formula: { 26 | expression: 'prop("X") + prop("Y")', 27 | }, 28 | }, 29 | }; 30 | 31 | const params = { 32 | parent: { 33 | type: 'page_id', 34 | page_id: argv.parentId, 35 | }, 36 | icon: props.emoji('📀'), 37 | title: [props.text(argv.title)], 38 | properties, 39 | }; 40 | 41 | (async () => { 42 | const database = await notion.databases.create(params); 43 | 44 | log(database); 45 | })(); 46 | -------------------------------------------------------------------------------- /examples/databases/retrieve/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Arguments: 3 | * 4 | * --database-id: ID of the page to fetch 5 | */ 6 | 7 | const { notion, yargs } = require('../../shared'); 8 | const { log } = require('../../shared/utils'); 9 | 10 | const databaseId = '87a5721f46b146dca5b3bddf414e9f00'; 11 | const argv = yargs.default({ databaseId }).argv; 12 | 13 | (async () => { 14 | const db = await notion.databases.retrieve({ 15 | database_id: argv.databaseId, 16 | }); 17 | 18 | log(db); 19 | })(); 20 | -------------------------------------------------------------------------------- /examples/databases/search/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Search a database by query. 3 | * 4 | * Arguments: 5 | * 6 | * --database-id: database ID to search 7 | * --query: search string 8 | * --page-size: how many to fetch per page 9 | */ 10 | 11 | const { notion, yargs } = require('../../shared'); 12 | const { log } = require('../../shared/utils'); 13 | 14 | const databaseId = '7354557becb34d72b6140bb541ac529a'; 15 | 16 | const argv = yargs.default({ 17 | databaseId, 18 | query: '', 19 | pageSize: 100, 20 | }).argv; 21 | 22 | // Search the database for any pages that have a "Task" property (a title property) 23 | // that contains the text "Hall". This is a non-exact match, so would find: 24 | // "Hall of Fame" and "Hall 1 Lighting". 25 | (async () => { 26 | const response = await notion.databases.query({ 27 | database_id: argv.databaseId, 28 | query: argv.query, 29 | filter: { 30 | property: 'Task', 31 | rich_text: { 32 | contains: 'Hall', 33 | }, 34 | }, 35 | page_size: argv.pageSize, 36 | }); 37 | 38 | log(response.results); 39 | })(); 40 | -------------------------------------------------------------------------------- /examples/databases/sort-multi-select/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sort a multi-select's options alphabetically. 3 | * 4 | * Arguments: 5 | * 6 | * --database-id: ID of the database to create in 7 | * --sort-prop: name of multi-select to sort 8 | * --[no-]case-sensitive: whether to sort ABCabc (default or --case-sensitive) or AaBbCc (--no-case-sensitive) 9 | */ 10 | 11 | const { notion, yargs } = require('../../shared'); 12 | const { log } = require('../../shared/utils'); 13 | const _ = require('lodash'); 14 | 15 | const databaseId = 'f91e66f29d63457894be7c91b132f345'; 16 | const sortProp = 'Feeling'; 17 | const argv = yargs 18 | .boolean('case-sensitive') 19 | .default({ databaseId, sortProp, caseSensitive: true }).argv; 20 | 21 | (async () => { 22 | let database = await notion.databases.retrieve({ 23 | database_id: argv.databaseId, 24 | }); 25 | 26 | const propId = database.properties[argv.sortProp].id; 27 | const iteratee = argv.caseSensitive ? 'name' : [(option) => option.name.toLowerCase()]; 28 | 29 | // Sort the options and remove color since it cannot be updated via API 30 | const sortedOptions = _.map( 31 | _.orderBy(database.properties[argv.sortProp].multi_select.options, iteratee), 32 | (option) => { 33 | return _.omit(option, 'color'); 34 | } 35 | ); 36 | 37 | const properties = { 38 | database_id: argv.databaseId, 39 | properties: { 40 | [propId]: { 41 | multi_select: { 42 | options: sortedOptions, 43 | }, 44 | }, 45 | }, 46 | }; 47 | 48 | database = await notion.databases.update(properties); 49 | 50 | log(database.properties[argv.sortProp]); 51 | })(); 52 | -------------------------------------------------------------------------------- /examples/databases/update/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Update a database. 3 | * 4 | * This example updates a formula, renames the database, and switches an icon. 5 | * 6 | * Works great when used with the database created with the ../create script! 7 | * 8 | * Arguments: 9 | * 10 | * --database-id: ID of the database to update 11 | * --prop-id: ID of property to update 12 | * --title: Title of the database to change to 13 | */ 14 | 15 | const { notion, yargs } = require('../../shared'); 16 | const props = require('../../shared/props'); 17 | const { log } = require('../../shared/utils'); 18 | 19 | const databaseId = '46956428bdf547bc8fc40263d854dabc'; 20 | const title = 'Updated Database Name'; 21 | const propId = 'f%5Csj'; 22 | const argv = yargs.default({ databaseId, propId, title }).argv; 23 | 24 | const properties = { 25 | [argv.propId]: { 26 | name: 'X * Y', 27 | formula: { 28 | expression: 'prop("X") * prop("Y")', 29 | }, 30 | }, 31 | }; 32 | 33 | const params = { 34 | database_id: argv.databaseId, 35 | icon: props.emoji('💽'), 36 | title: [props.text(argv.title)], 37 | properties, 38 | }; 39 | 40 | (async () => { 41 | const database = await notion.databases.update(params); 42 | 43 | log(database); 44 | })(); 45 | -------------------------------------------------------------------------------- /examples/files/data/example.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeoneerror/notion-api-examples/94189eb7658bd08f20db2fdd83d438439805f58e/examples/files/data/example.mp4 -------------------------------------------------------------------------------- /examples/files/data/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeoneerror/notion-api-examples/94189eb7658bd08f20db2fdd83d438439805f58e/examples/files/data/example.png -------------------------------------------------------------------------------- /examples/files/data/example.txt: -------------------------------------------------------------------------------- 1 | Here's an example file. 2 | -------------------------------------------------------------------------------- /examples/files/upload-cover.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { notion, yargs } = require('../shared'); 3 | const { uploadCoverImage } = require('../shared/files'); 4 | const props = require('../shared/props'); 5 | const titledDate = require('../shared/titled-date'); 6 | const { log } = require('../shared/utils'); 7 | 8 | const argv = yargs 9 | .option('pageId', { 10 | alias: 'p', 11 | describe: 'The ID of the page to add the cover to', 12 | }) 13 | .option('databaseId', { 14 | alias: 'd', 15 | describe: 'The ID of the database to create the page in', 16 | default: process.env.OURA_JOURNAL_DATABASE_ID, 17 | }).argv; 18 | 19 | const filePath = path.join(__dirname, 'data/example.png'); 20 | 21 | (async () => { 22 | let page; 23 | 24 | if (!argv.pageId) { 25 | // Create a new page 26 | page = await notion.pages.create({ 27 | parent: { 28 | type: 'database_id', 29 | database_id: argv.databaseId, 30 | }, 31 | icon: props.emoji('📄'), 32 | properties: titledDate('Journal'), 33 | }); 34 | } else { 35 | // Get the existing page 36 | page = await notion.pages.retrieve({ 37 | page_id: argv.pageId, 38 | }); 39 | } 40 | 41 | // Upload a file to the page in the "Daily Photo" property 42 | const upload = await uploadCoverImage(filePath, page); 43 | 44 | log(page); 45 | })(); 46 | -------------------------------------------------------------------------------- /examples/files/upload-file.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { notion, yargs } = require('../shared'); 3 | const { uploadFileAttachment, attachFileToPage } = require('../shared/files'); 4 | const props = require('../shared/props'); 5 | const titledDate = require('../shared/titled-date'); 6 | const { log } = require('../shared/utils'); 7 | 8 | const argv = yargs.argv; 9 | const databaseId = argv.databaseId || process.env.OURA_JOURNAL_DATABASE_ID; 10 | 11 | const filePath = path.join(__dirname, 'data/example.txt'); 12 | const properties = titledDate('Uploaded File'); 13 | 14 | const params = { 15 | parent: { 16 | type: 'database_id', 17 | database_id: databaseId, 18 | }, 19 | icon: props.emoji('📄'), 20 | properties, 21 | }; 22 | 23 | (async () => { 24 | // Create a new page 25 | const page = await notion.pages.create(params); 26 | 27 | // Upload a file to the page in the "Daily Photo" property 28 | const upload = await uploadFileAttachment(filePath, page, 'Daily Photo', 'Example File'); 29 | 30 | // Upload a file to the page's body 31 | await attachFileToPage(upload, page, 'Example File'); 32 | 33 | log(page); 34 | })(); 35 | -------------------------------------------------------------------------------- /examples/files/upload-multipart.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { notion, yargs } = require('../shared'); 3 | const { uploadFileAttachment, attachFileToPage } = require('../shared/files'); 4 | const props = require('../shared/props'); 5 | const titledDate = require('../shared/titled-date'); 6 | const { log } = require('../shared/utils'); 7 | 8 | const argv = yargs.argv; 9 | const databaseId = argv.databaseId || process.env.OURA_JOURNAL_DATABASE_ID; 10 | 11 | const filePath = path.join(__dirname, 'data/example.mp4'); 12 | const properties = titledDate('Uploaded Video'); 13 | 14 | const params = { 15 | parent: { 16 | type: 'database_id', 17 | database_id: databaseId, 18 | }, 19 | icon: props.emoji('📄'), 20 | properties, 21 | }; 22 | 23 | (async () => { 24 | // Create a new page 25 | const page = await notion.pages.create(params); 26 | 27 | // Upload a file to the page in the "Daily Photo" property 28 | const upload = await uploadFileAttachment(filePath, page, 'Daily Photo', 'Video File'); 29 | 30 | // Upload a video to the page's body 31 | // Block type is inferred from the file's content type 32 | await attachFileToPage(upload, page); 33 | 34 | log(page); 35 | })(); 36 | -------------------------------------------------------------------------------- /examples/formula1/circuits.js: -------------------------------------------------------------------------------- 1 | const orderBy = require('lodash/orderBy'); 2 | const { RateLimit } = require('async-sema'); 3 | 4 | const { dashboardId, ergast, log, notion, props } = require('./shared'); 5 | 6 | const DB_TITLE = 'F1 Circuits'; 7 | 8 | // Writes to databases need a lot of time to settle, so we'll 9 | // limit our requests to 1 per 2 seconds. In real scenario, we'd want to push 10 | // these to some sort of background queuing system and allow them to be retried 11 | // when they fail. 12 | const limit = RateLimit(1, { timeUnit: 2000, uniformDistribution: true }); 13 | 14 | async function createCircuitsDatabase() { 15 | const response = await notion.search({ 16 | query: DB_TITLE, 17 | }); 18 | 19 | if (response.results.length) { 20 | console.log(`Using existing database ${response.results[0].id}`); 21 | 22 | return response.results[0]; 23 | } 24 | 25 | const properties = { 26 | 'Circuit Name': { title: {} }, 27 | 'Circuit Id': { rich_text: {} }, 28 | Locality: { select: {} }, 29 | Country: { select: {} }, 30 | Latitude: { number: {} }, 31 | Longitude: { number: {} }, 32 | URL: { url: {} }, 33 | ...props.timestamps(), 34 | }; 35 | 36 | console.log(`Creating "${DB_TITLE}" database`); 37 | 38 | return await notion.databases.create({ 39 | parent: { 40 | type: 'page_id', 41 | page_id: dashboardId, 42 | }, 43 | icon: props.emoji('🛤️'), 44 | title: [props.text(DB_TITLE)], 45 | properties, 46 | }); 47 | } 48 | 49 | async function fetchCircuits() { 50 | console.log('Fetching circuits from ergast API'); 51 | 52 | const circuits = await ergast('circuits', 'CircuitTable.Circuits', { 53 | limit: 100, 54 | }); 55 | 56 | return orderBy(circuits, 'circuitName', 'desc'); 57 | } 58 | 59 | async function createCircuit(database, circuit) { 60 | process.stdout.write('.'); 61 | 62 | const { 63 | Location: { country, lat, locality, long }, 64 | } = circuit; 65 | 66 | const properties = { 67 | 'Circuit Name': props.pageTitle(circuit.circuitName), 68 | 'Circuit Id': props.richText(circuit.circuitId), 69 | Locality: props.select(locality), 70 | Country: props.select(country), 71 | Latitude: props.number(lat), 72 | Longitude: props.number(long), 73 | URL: props.url(circuit.url), 74 | }; 75 | 76 | return await notion.pages.create({ 77 | parent: { 78 | database_id: database.id, 79 | }, 80 | properties, 81 | }); 82 | } 83 | 84 | async function createCircuits(database, circuits) { 85 | process.stdout.write('Creating circuits...'); 86 | 87 | return await Promise.all( 88 | circuits.map(async (circuit) => { 89 | await limit(); 90 | return await createCircuit(database, circuit); 91 | }) 92 | ); 93 | } 94 | 95 | (async () => { 96 | const circuitsDatabase = await createCircuitsDatabase(); 97 | const ergastCircuits = await fetchCircuits(); 98 | const circuits = await createCircuits(circuitsDatabase, ergastCircuits); 99 | 100 | log(circuits); 101 | })(); 102 | -------------------------------------------------------------------------------- /examples/formula1/shared.js: -------------------------------------------------------------------------------- 1 | const { default: axios } = require('axios'); 2 | const _ = require('lodash'); 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | 6 | const { notion, yargs } = require('../shared'); 7 | 8 | const { log } = require('../shared/utils'); 9 | const props = require('../shared/props'); 10 | 11 | // https://www.notion.so/okidoki/Formula-n-af6bf67b991c4de5a607e10de99d7cf3 12 | const dashboardId = 'af6bf67b991c4de5a607e10de99d7cf3'; 13 | 14 | const ergastBaseUrl = 'https://ergast.com/api/f1'; 15 | const tmpDir = path.join(__dirname, 'tmp'); 16 | 17 | async function ergast(resource, dataProp, params = {}) { 18 | const query = new URLSearchParams(params); 19 | const buff = Buffer.from(query.toString(), 'utf-8').toString('base64'); 20 | const fileName = `${resource}.${buff}.json`; 21 | const cachePath = path.join(tmpDir, fileName); 22 | 23 | if (fs.existsSync(cachePath)) { 24 | return JSON.parse( 25 | fs.readFileSync(cachePath, { 26 | encoding: 'utf-8', 27 | flag: 'r', 28 | }) 29 | ); 30 | } 31 | 32 | const url = new URL(`${ergastBaseUrl}/${resource}.json`); 33 | url.search = query; 34 | 35 | try { 36 | const { 37 | data: { MRData }, 38 | } = await axios.get(url.href); 39 | const data = _.get(MRData, dataProp); 40 | 41 | if (!fs.existsSync(tmpDir)) { 42 | fs.mkdirSync(tmpDir); 43 | } 44 | 45 | fs.writeFileSync(cachePath, JSON.stringify(data), 'utf-8'); 46 | 47 | return data; 48 | } catch (error) { 49 | console.error(error.toString()); 50 | } 51 | } 52 | 53 | module.exports = { 54 | dashboardId, 55 | ergast, 56 | log, 57 | notion, 58 | props, 59 | yargs, 60 | }; 61 | -------------------------------------------------------------------------------- /examples/nm/align-member-ids.js: -------------------------------------------------------------------------------- 1 | const { notion } = require('../shared'); 2 | const { fetchAllPages, performWithAll } = require('../shared/fetch-pages'); 3 | const { log } = require('../shared/utils'); 4 | const { getCache } = require('./shared'); 5 | 6 | const studentsDbId = '9d29ced8e9ba467c84e74fabbbbacc01'; 7 | 8 | async function updateStudent(page, users) { 9 | const { 10 | properties: { 11 | Email: { email }, 12 | }, 13 | } = page; 14 | 15 | const user = users.find((m) => { 16 | return m['email'] == email; 17 | }); 18 | 19 | let properties = { 20 | Missing: { 21 | checkbox: false, 22 | }, 23 | }; 24 | 25 | if (user) { 26 | properties = { 27 | ...properties, 28 | NMID: { 29 | rich_text: [ 30 | { 31 | text: { 32 | content: user['id'], 33 | }, 34 | }, 35 | ], 36 | }, 37 | }; 38 | } else { 39 | properties.Missing.checkbox = true; 40 | } 41 | 42 | // log(page.properties.Name.title[0].plain_text); 43 | // log(properties); 44 | 45 | return await notion.pages.update({ 46 | page_id: page.id, 47 | properties, 48 | }); 49 | } 50 | 51 | (async () => { 52 | const users = await getCache('members'); 53 | 54 | const pages = await fetchAllPages(studentsDbId, { 55 | filter: { 56 | and: [ 57 | { 58 | property: 'NMID', 59 | rich_text: { 60 | is_empty: true, 61 | }, 62 | }, 63 | { 64 | property: 'Status', 65 | select: { 66 | equals: 'Active', 67 | }, 68 | }, 69 | { 70 | property: 'Missing', 71 | checkbox: { 72 | equals: false, 73 | }, 74 | }, 75 | ], 76 | }, 77 | }); 78 | 79 | // console.log(pages.length); 80 | 81 | await performWithAll(pages, updateStudent, [users]); 82 | })(); 83 | -------------------------------------------------------------------------------- /examples/nm/align-user-ids.js: -------------------------------------------------------------------------------- 1 | const { notion } = require('../shared'); 2 | const { fetchAllPages, performWithAll } = require('../shared/fetch-pages'); 3 | const { log } = require('../shared/utils'); 4 | const { getCache } = require('./shared'); 5 | 6 | const studentsDbId = '9d29ced8e9ba467c84e74fabbbbacc01'; 7 | 8 | async function updateStudent(page, users) { 9 | const user = users.find((m) => { 10 | return m['OKID'] == page.id.replaceAll('-', ''); 11 | }); 12 | 13 | let properties = { 14 | Missing: { 15 | checkbox: false, 16 | }, 17 | }; 18 | 19 | if (user) { 20 | properties = { 21 | ...properties, 22 | NMID: { 23 | rich_text: [ 24 | { 25 | text: { 26 | content: user['NMID'], 27 | }, 28 | }, 29 | ], 30 | }, 31 | }; 32 | } else { 33 | properties.Missing.checkbox = true; 34 | } 35 | 36 | // log(page.properties.Name.title[0].plain_text); 37 | // log(properties); 38 | 39 | return await notion.pages.update({ 40 | page_id: page.id, 41 | properties, 42 | }); 43 | } 44 | 45 | (async () => { 46 | const data = await getCache('members-nm-groups'); 47 | const nm = data['nm']['found']; 48 | const legacy = data['legacy']['found']; 49 | const users = nm.concat(legacy); 50 | 51 | const pages = await fetchAllPages(studentsDbId, { 52 | filter: { 53 | and: [ 54 | { 55 | property: 'NMID', 56 | rich_text: { 57 | is_empty: true, 58 | }, 59 | }, 60 | { 61 | property: 'Status', 62 | select: { 63 | equals: 'Active', 64 | }, 65 | }, 66 | { 67 | property: 'Missing', 68 | checkbox: { 69 | equals: false, 70 | }, 71 | }, 72 | ], 73 | }, 74 | }); 75 | 76 | // console.log(pages.length); 77 | 78 | await performWithAll(pages, updateStudent, [users]); 79 | })(); 80 | -------------------------------------------------------------------------------- /examples/nm/bulk-member-add-by-email.js: -------------------------------------------------------------------------------- 1 | const { RateLimit } = require('async-sema'); 2 | const { RED_COLOR, yargs } = require('../shared/scim'); 3 | const { addMemberToGroup, findMemberByEmail, findOrProvisionUser, getCache } = require('./shared'); 4 | 5 | const RPS = 3; 6 | const limit = RateLimit(RPS); 7 | 8 | const argv = yargs 9 | .option('groupId', { 10 | alias: 'g', 11 | describe: 'The ID of the group to add the User to', 12 | demand: true, 13 | default: '7d3e5712-a873-43a8-a4b5-2ab138a9e2ea', 14 | }) 15 | .option('file', { 16 | alias: 'f', 17 | describe: 'File of user IDs to add to group', 18 | demand: true, 19 | default: 'members-import', 20 | }) 21 | .boolean('provision') 22 | .default({ provision: false }).argv; 23 | 24 | async function addMember(groupId, user, provision = false) { 25 | await limit(); 26 | 27 | const { email } = user; 28 | 29 | let member; 30 | 31 | if (provision) { 32 | member = await findOrProvisionUser(email); 33 | } else { 34 | member = await findMemberByEmail(email); 35 | 36 | if (!member) { 37 | console.log(RED_COLOR, `No member by email <${email}> found`); 38 | return; 39 | } 40 | } 41 | 42 | await limit(); 43 | 44 | return await addMemberToGroup(groupId, member.id); 45 | } 46 | 47 | (async () => { 48 | const users = await getCache(argv.file); 49 | 50 | for (const user of users) { 51 | await addMember(argv.groupId, user, argv.provision); 52 | } 53 | 54 | console.log(`${users.length} added to group`); 55 | })(); 56 | -------------------------------------------------------------------------------- /examples/nm/bulk-member-add.js: -------------------------------------------------------------------------------- 1 | const { RateLimit } = require('async-sema'); 2 | const { addMemberToGroup, getCache } = require('./shared'); 3 | 4 | const RPS = 1; 5 | const limit = RateLimit(RPS); 6 | 7 | async function addMember(groupId, user) { 8 | await limit(); 9 | 10 | const { memberName, newEmail, email, NMID } = user; 11 | 12 | console.log(`Added ${memberName} <${newEmail || email}> (${NMID}) to ${groupId}`); 13 | 14 | return await addMemberToGroup(groupId, NMID); 15 | } 16 | 17 | async function addMembers(groupId, cacheName) { 18 | const users = await getCache(cacheName); 19 | 20 | console.log(`Adding ${users.length} members to group ${groupId}`); 21 | 22 | for (const user of users) { 23 | await addMember(groupId, user); 24 | } 25 | 26 | console.log(`${users.length} added to group`); 27 | } 28 | 29 | (async () => { 30 | await addMembers('7d3e5712-a873-43a8-a4b5-2ab138a9e2ea', 'members-import-nm'); 31 | await addMembers('922f01d5-b5e4-4f13-9be7-411242a2c68b', 'members-import-legacy'); 32 | })(); 33 | -------------------------------------------------------------------------------- /examples/nm/bulk-member-remove.js: -------------------------------------------------------------------------------- 1 | const { RateLimit } = require('async-sema'); 2 | const { 3 | findMember, 4 | getCache, 5 | removeMemberFromGroup, 6 | removeMemberFromWorkspace, 7 | } = require('./shared'); 8 | const { notion } = require('../shared'); 9 | const { findAndRemoveCircleMember } = require('../shared/circle'); 10 | const { findAndTagConvertkitSubscriber } = require('../shared/convertkit'); 11 | const { RED_COLOR, yargs } = require('../shared/scim'); 12 | 13 | const argv = yargs 14 | .option('i', { 15 | alias: 'id', 16 | type: 'string', 17 | describe: 'The ID of the Student in the Student database', 18 | }) 19 | .option('c', { 20 | alias: 'complete', 21 | type: 'boolean', 22 | describe: 'Totally remove the Student from the NOTION MASTERY workspace?', 23 | default: false, 24 | }).argv; 25 | 26 | const RPS = 1; 27 | const limit = RateLimit(RPS); 28 | const DIV = '~~~~~~~~~~'; 29 | 30 | async function removeMember(user, complete = false) { 31 | await limit(); 32 | 33 | const memberName = user['Name']; 34 | const email = user['Email']; 35 | const NMID = user['NMID']; 36 | const OKID = user['OKID']; 37 | const previousEmail = user['Previous Email']; 38 | 39 | console.log(`Removing ${memberName} <${email}> (${NMID})`); 40 | 41 | const member = await findMember(NMID); 42 | 43 | if (member) { 44 | if (complete) { 45 | // Remove entirely... 46 | await removeMemberFromWorkspace(NMID); 47 | } else { 48 | // Remove from Notion Mastery groups but keep in workspace 49 | await removeMemberFromGroup('7d3e5712-a873-43a8-a4b5-2ab138a9e2ea', NMID); 50 | await removeMemberFromGroup('9e7b05bc-e9e6-4b7a-8246-f8b1af875ea2', NMID); 51 | } 52 | } else { 53 | console.log(RED_COLOR, `Could not find ${memberName} <${email}> (${NMID})`); 54 | } 55 | 56 | await findAndRemoveCircleMember(email); 57 | if (previousEmail && previousEmail != email) { 58 | await findAndRemoveCircleMember(previousEmail); 59 | } 60 | 61 | await findAndTagConvertkitSubscriber(email); 62 | if (previousEmail && previousEmail != email) { 63 | await findAndTagConvertkitSubscriber(previousEmail); 64 | } 65 | 66 | return await notion.pages.update({ 67 | page_id: OKID, 68 | properties: { 69 | Status: { 70 | select: { 71 | name: 'Removed', 72 | }, 73 | }, 74 | }, 75 | }); 76 | } 77 | 78 | (async () => { 79 | let users = []; 80 | 81 | if (argv.id) { 82 | const { properties } = await notion.pages.retrieve({ 83 | page_id: argv.id, 84 | }); 85 | 86 | let { 87 | Name: { 88 | title: [{ plain_text: Name }], 89 | }, 90 | Email: { email: Email }, 91 | NMID: { 92 | rich_text: [{ plain_text: NMID }], 93 | }, 94 | 'Previous Email': { email: previousEmail }, 95 | } = properties; 96 | 97 | users = [ 98 | { 99 | Name, 100 | Email, 101 | NMID, 102 | OKID: argv.id, 103 | 'Previous Email': previousEmail, 104 | }, 105 | ]; 106 | } else { 107 | users = await getCache('members-expired'); 108 | } 109 | 110 | console.log(`Removing ${users.length} members`); 111 | console.log(DIV); 112 | 113 | for (const user of users) { 114 | await removeMember(user, argv.complete); 115 | console.log(DIV); 116 | } 117 | 118 | console.log(`${users.length} removed`); 119 | })(); 120 | -------------------------------------------------------------------------------- /examples/nm/change-member-emails.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Change access from one email to another. 3 | */ 4 | 5 | const { RED_COLOR, yargs } = require('../shared/scim'); 6 | const { 7 | addMemberToGroup, 8 | findMemberByEmail, 9 | findOrProvisionUser, 10 | removeMemberFromWorkspace, 11 | } = require('./shared'); 12 | 13 | const argv = yargs 14 | .option('groupId', { 15 | alias: 'g', 16 | describe: 'The ID of the group to add the User to', 17 | demand: true, 18 | default: '7d3e5712-a873-43a8-a4b5-2ab138a9e2ea', 19 | }) 20 | .option('old', { 21 | alias: 'o', 22 | describe: "User's current email address", 23 | demand: true, 24 | }) 25 | .option('new', { 26 | alias: 'n', 27 | describe: "User's new email address", 28 | demand: true, 29 | }).argv; 30 | 31 | (async () => { 32 | const { groupId, old: oldEmail, new: newEmail } = argv; 33 | 34 | // Find the old member 35 | const oldMember = await findMemberByEmail(oldEmail); 36 | if (!oldMember) { 37 | return console.log(RED_COLOR, `No member by email <${oldEmail}> found`); 38 | } 39 | 40 | // Provision the new member 41 | const user = await findOrProvisionUser(newEmail); 42 | if (!user.id) { 43 | return console.log(RED_COLOR, 'Could not find or provision user'); 44 | } 45 | 46 | await addMemberToGroup(argv.groupId, user.id); 47 | await removeMemberFromWorkspace(oldMember.id); 48 | })(); 49 | -------------------------------------------------------------------------------- /examples/nm/data/.gitignore: -------------------------------------------------------------------------------- 1 | members* 2 | -------------------------------------------------------------------------------- /examples/nm/data/groups.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "displayName": "AW Example Editors", 4 | "id": "901fc64c-1167-4a43-8a3c-b906f08858a9", 5 | "memberCount": 0 6 | }, 7 | { 8 | "displayName": "Admin", 9 | "id": "a35ee21b-4d22-4082-a33d-2db565a043bd", 10 | "memberCount": 3 11 | }, 12 | { 13 | "displayName": "Architecting Workspaces", 14 | "id": "0a5337ff-8343-4da6-a285-fe5c48b9660f", 15 | "memberCount": 0 16 | }, 17 | { 18 | "displayName": "Content Editors", 19 | "id": "3a059829-3a29-4b44-b1c6-d41b0e0cf727", 20 | "memberCount": 1 21 | }, 22 | { 23 | "displayName": "Contributors", 24 | "id": "4c179c85-96b1-4dd5-8858-a89ae5acc4ca", 25 | "memberCount": 2 26 | }, 27 | { 28 | "displayName": "Curriculum", 29 | "id": "28724504-caac-4f4b-975f-e2c4b1907d06", 30 | "memberCount": 3 31 | }, 32 | { 33 | "displayName": "Formula Fundamentals 2.0", 34 | "id": "70158620-4985-4b86-b08e-95657b6d2edf", 35 | "memberCount": 117 36 | }, 37 | { 38 | "displayName": "Notion Mastery", 39 | "id": "7d3e5712-a873-43a8-a4b5-2ab138a9e2ea", 40 | "memberCount": 522 41 | }, 42 | { 43 | "displayName": "Notion Mastery Alumni", 44 | "id": "922f01d5-b5e4-4f13-9be7-411242a2c68b", 45 | "memberCount": 1160 46 | }, 47 | { 48 | "displayName": "Notion Mastery Membership", 49 | "id": "9e7b05bc-e9e6-4b7a-8246-f8b1af875ea2", 50 | "memberCount": 289 51 | }, 52 | { 53 | "displayName": "Team", 54 | "id": "6b4b5525-5248-4bee-9d4a-05d5114b58b9", 55 | "memberCount": 4 56 | } 57 | ] -------------------------------------------------------------------------------- /examples/nm/data/scim-group-add.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], 3 | "Operations": [ 4 | { 5 | "op": "Add", 6 | "path": "members", 7 | "value": [ 8 | { 9 | "value": "8fe75cf9-8a28-484e-aed8-149e8293d5fc" 10 | } 11 | ] 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /examples/nm/data/scim-user-add.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], 3 | "userName": "ben+ff2@wareokidoki.com" 4 | } 5 | -------------------------------------------------------------------------------- /examples/nm/list-groups.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Output a list of all groups in the Notion Mastery workspace. 3 | */ 4 | 5 | const _ = require('lodash'); 6 | const { scim, yargs } = require('../shared/scim'); 7 | const { log } = require('../shared/utils'); 8 | const { setCache } = require('./shared'); 9 | 10 | (async () => { 11 | // GET https://api.notion.com/scim/v2/Groups 12 | 13 | const params = { 14 | count: 100, 15 | startIndex: 1, 16 | }; 17 | 18 | try { 19 | let { 20 | data: { Resources: groups }, 21 | } = await scim.get('Groups', { params }); 22 | 23 | // Formats groups into simple format showing group name, id, 24 | // and a count showing the number of members in the group. 25 | 26 | groups = _.reduce( 27 | groups, 28 | (all, group) => { 29 | all.push({ 30 | displayName: group.displayName, 31 | id: group.id, 32 | memberCount: group.members.length || 0, 33 | }); 34 | 35 | return all; 36 | }, 37 | [] 38 | ); 39 | 40 | groups = _.orderBy(groups, 'displayName'); 41 | 42 | await setCache('groups', groups); 43 | 44 | log(groups); 45 | } catch (e) { 46 | console.log(e); 47 | } 48 | })(); 49 | -------------------------------------------------------------------------------- /examples/nm/list-legacy.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fetch all legacy customers from the "NM Student Database". 3 | */ 4 | 5 | const { RateLimit } = require('async-sema'); 6 | const { fetchAllPages } = require('../shared/fetch-pages'); 7 | const { log } = require('../shared/utils'); 8 | const { setCache } = require('./shared'); 9 | 10 | const rateLimiter = RateLimit(5); 11 | 12 | (async () => { 13 | const query = { 14 | filter: { 15 | and: [ 16 | { 17 | property: 'Lifetime (A)', 18 | checkbox: { 19 | equals: true, 20 | }, 21 | }, 22 | { 23 | property: 'Status', 24 | select: { 25 | equals: 'Active', 26 | }, 27 | }, 28 | ], 29 | }, 30 | }; 31 | 32 | const students = await fetchAllPages('9d29ced8e9ba467c84e74fabbbbacc01', query, rateLimiter); 33 | 34 | await setCache('members-legacy', students); 35 | 36 | log(students.length); 37 | })(); 38 | -------------------------------------------------------------------------------- /examples/nm/list-members.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Output all Members of the space, regardless of status. 3 | */ 4 | 5 | const _ = require('lodash'); 6 | const { RateLimit } = require('async-sema'); 7 | const { scim, yargs } = require('../shared/scim'); 8 | const { log } = require('../shared/utils'); 9 | const { setCache } = require('./shared'); 10 | 11 | const PER_PAGE = 100; 12 | const RPS = 2; 13 | 14 | const limit = RateLimit(RPS); 15 | 16 | let allUsers = []; 17 | 18 | async function fetchUsers(page = 1) { 19 | const startIndex = 1 + (page - 1) * PER_PAGE; 20 | console.log(`Fetching page ${page} at startIndex ${startIndex}...`); 21 | 22 | const params = { 23 | count: PER_PAGE, 24 | startIndex, 25 | }; 26 | 27 | try { 28 | let data = await scim.get('Users', { params }); 29 | let { 30 | data: { Resources: users, totalResults }, 31 | } = data; 32 | 33 | allUsers.push(...users); 34 | 35 | let remaining = totalResults - (startIndex + PER_PAGE - 1); 36 | if (remaining > 0) { 37 | console.log(`\t${remaining} items remaining to fetch, total is ${allUsers.length}...`); 38 | 39 | await limit(); 40 | await fetchUsers(page + 1); 41 | } 42 | } catch (e) { 43 | console.log(e); 44 | } 45 | } 46 | 47 | (async () => { 48 | await fetchUsers(); 49 | 50 | allUsers = _.reduce( 51 | allUsers, 52 | (all, user) => { 53 | all.push({ 54 | active: user.active, 55 | displayName: user.displayName, 56 | email: _.find(user.emails, { primary: true }).value, 57 | emails: user.emails, 58 | id: user.id, 59 | name: user.name, 60 | }); 61 | 62 | return all; 63 | }, 64 | [] 65 | ); 66 | 67 | console.log(`${allUsers.length} users fetched.`); 68 | 69 | await setCache('members', allUsers); 70 | })(); 71 | -------------------------------------------------------------------------------- /examples/nm/member-add-all.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Add a new or existing Member to a Notion Group, Circle, and tag properly in ConvertKit. 3 | */ 4 | 5 | const { notion } = require('../shared'); 6 | const { addCircleMember, findCircleMember } = require('../shared/circle'); 7 | const { removeConvertkitTag } = require('../shared/convertkit'); 8 | const { yargs, RED_COLOR } = require('../shared/scim'); 9 | const { addMemberToGroup, findOrProvisionUser } = require('./shared'); 10 | 11 | const argv = yargs 12 | .option('groupId', { 13 | alias: 'g', 14 | describe: 'The ID of the group to add the User to', 15 | demand: true, 16 | default: '7d3e5712-a873-43a8-a4b5-2ab138a9e2ea', 17 | }) 18 | .option('email', { 19 | alias: 'e', 20 | describe: "User's email address", 21 | }).argv; 22 | 23 | (async () => { 24 | const email = argv.email; 25 | 26 | // Add to Notion as Member 27 | const user = await findOrProvisionUser(email); 28 | 29 | if (!user) { 30 | return console.log(RED_COLOR, 'Could not find or provision user in Notion'); 31 | } 32 | 33 | // Add to Notion Group 34 | await addMemberToGroup(argv.groupId, user.id); 35 | 36 | // Invite to Circle 37 | let circleMember = await findCircleMember(email); 38 | 39 | if (!circleMember) { 40 | const { 41 | name: { formatted: name }, 42 | } = user; 43 | 44 | console.log(`Adding ${name} <${email}> to Circle`); 45 | 46 | circleMember = await addCircleMember(email, name); 47 | 48 | if (!circleMember) { 49 | console.log(RED_COLOR, `Could not add <${email}> to Circle`); 50 | } 51 | } 52 | 53 | removeConvertkitTag(email); 54 | })(); 55 | -------------------------------------------------------------------------------- /examples/nm/member-add.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Add a new or existing Member to a Group. 3 | * 4 | * 1. Fetch a Member in Notion Mastery, if they don't exist then, 5 | * 2. Provision a new User/Member in Notion Mastery, and then, 6 | * 3. Add the User provisioned to the Group determined by --group-id option. 7 | */ 8 | 9 | const { yargs } = require('../shared/scim'); 10 | const { addMemberToGroup, findOrProvisionUser } = require('./shared'); 11 | 12 | const argv = yargs 13 | .option('groupId', { 14 | alias: 'g', 15 | describe: 'The ID of the group to add the User to', 16 | }) 17 | .option('groupKey', { 18 | alias: 'k', 19 | describe: 'Group key (nm or membership or ff)', 20 | choices: ['nm', 'membership', 'ff'], 21 | }) 22 | .option('email', { 23 | alias: 'e', 24 | describe: "User's email address", 25 | }) 26 | .option('userId', { 27 | alias: 'u', 28 | describe: "User's Notion identifier", 29 | }).argv; 30 | 31 | // Group key to ID hash 32 | const groupKeyToId = { 33 | nm: '7d3e5712-a873-43a8-a4b5-2ab138a9e2ea', // Notion Mastery 34 | membership: '9e7b05bc-e9e6-4b7a-8246-f8b1af875ea2', // Notion Mastery Membership 35 | ff: '70158620-4985-4b86-b08e-95657b6d2edf', // Formula Fundamentals 2.0 36 | }; 37 | 38 | (async () => { 39 | let userId = argv.userId; 40 | const groupKey = argv.groupKey || 'nm'; 41 | const groupId = argv.groupId || groupKeyToId[groupKey]; 42 | 43 | if (!(userId || argv.email)) { 44 | return console.log('Need either a userId or email'); 45 | } else if (!userId) { 46 | const user = await findOrProvisionUser(argv.email); 47 | userId = user.id; 48 | } 49 | 50 | if (!userId) { 51 | return console.log('Could not find or provision user'); 52 | } 53 | 54 | await addMemberToGroup(groupId, userId); 55 | })(); 56 | -------------------------------------------------------------------------------- /examples/nm/member-remove.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Remove a Member from a Group. 3 | * 4 | * --group-id: ID of Group to remove from 5 | * --user-id: ID of User to remove 6 | */ 7 | 8 | const { yargs } = require('../shared/scim'); 9 | const { findMemberByEmail, removeMemberFromGroup } = require('./shared'); 10 | 11 | const argv = yargs 12 | .option('groupId', { 13 | alias: 'g', 14 | describe: 'The ID of the group to add the User to', 15 | demand: true, 16 | default: '7d3e5712-a873-43a8-a4b5-2ab138a9e2ea', 17 | }) 18 | .option('email', { 19 | alias: 'e', 20 | describe: "User's email address", 21 | }) 22 | .option('userId', { 23 | alias: 'u', 24 | describe: "User's Notion identifier", 25 | }).argv; 26 | 27 | (async () => { 28 | let userId = argv.userId; 29 | let email = argv.email; 30 | 31 | if (!(userId || email)) { 32 | return console.log('Need either a userId or email'); 33 | } else if (argv.email) { 34 | const user = await findMemberByEmail(email); 35 | userId = user.id; 36 | } 37 | 38 | if (!userId) { 39 | return console.log('Could not find user'); 40 | } 41 | 42 | await removeMemberFromGroup(argv.groupId, userId); 43 | })(); 44 | -------------------------------------------------------------------------------- /examples/nm/shared.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | const { RED_COLOR, scim, SCIM_SCHEMA_PATCH_OP, SCIM_SCHEMA_USER } = require('../shared/scim'); 4 | 5 | async function addMemberToGroup(groupId, userId) { 6 | // PATCH https://api.notion.com/scim/v2/Groups/{id} 7 | 8 | try { 9 | const { status, statusText } = await scim.patch(`Groups/${groupId}`, { 10 | schemas: [SCIM_SCHEMA_PATCH_OP], 11 | Operations: [ 12 | { 13 | op: 'Add', 14 | path: 'members', 15 | value: [ 16 | { 17 | value: userId, 18 | }, 19 | ], 20 | }, 21 | ], 22 | }); 23 | 24 | console.log(`${status}: ${statusText} - ${userId}`); 25 | } catch (e) { 26 | console.log('Error', e); 27 | } 28 | } 29 | 30 | async function removeMemberFromGroup(groupId, userId) { 31 | // PATCH https://api.notion.com/scim/v2/Groups/{id} 32 | 33 | try { 34 | const { status, statusText } = await scim.patch(`Groups/${groupId}`, { 35 | schemas: [SCIM_SCHEMA_PATCH_OP], 36 | Operations: [ 37 | { 38 | op: 'Remove', 39 | path: 'members', 40 | value: [ 41 | { 42 | value: userId, 43 | }, 44 | ], 45 | }, 46 | ], 47 | }); 48 | 49 | console.log(`${status}: ${statusText} - ${userId}`); 50 | } catch (e) { 51 | console.log(RED_COLOR, 'Error', e); 52 | } 53 | } 54 | 55 | async function findMember(userId) { 56 | try { 57 | const { data: user } = await scim.get(`Users/${userId}`); 58 | 59 | return { 60 | id: user.id, 61 | name: user.name.formatted, 62 | email: user.userName, 63 | }; 64 | } catch { 65 | return; 66 | } 67 | } 68 | 69 | async function findMemberByEmail(email) { 70 | console.log(`Finding member by email <${email}>`); 71 | 72 | try { 73 | let { 74 | data: { 75 | Resources: [user], 76 | }, 77 | } = await scim.get('Users', { 78 | params: { 79 | filter: `email eq "${email}"`, 80 | count: 1, 81 | }, 82 | }); 83 | 84 | return user; 85 | } catch (e) { 86 | console.log(e); 87 | } 88 | } 89 | 90 | async function findOrProvisionUser(email) { 91 | let user = await findMemberByEmail(email); 92 | 93 | if (!user) { 94 | const args = { userName: email }; 95 | 96 | // POST https://api.notion.com/scim/v2/Users 97 | // Note this will raise a 409 error if user already exists 98 | try { 99 | const { data } = await scim.post('Users', { 100 | schemas: [SCIM_SCHEMA_USER], 101 | ...args, 102 | }); 103 | 104 | user = data; 105 | } catch ({ response: { status } }) { 106 | console.log(status); 107 | } 108 | } 109 | 110 | return user; 111 | } 112 | 113 | async function removeMemberFromWorkspace(userId) { 114 | try { 115 | // DELETE https://api.notion.com/scim/v2/Users/{id} 116 | const { status, statusText } = await scim.delete(`Users/${userId}`); 117 | 118 | console.log(`Removed member (${status}): ${statusText} - ${userId}`); 119 | } catch ({ response: { status, statusText } }) { 120 | console.log(RED_COLOR, `Failed to remove member (${status}): ${statusText}`); 121 | } 122 | } 123 | 124 | async function getCache(fileName) { 125 | const tmpDir = path.join(__dirname, 'data'); 126 | const cachePath = path.join(tmpDir, `${fileName}.json`); 127 | 128 | if (fs.existsSync(cachePath)) { 129 | return JSON.parse( 130 | fs.readFileSync(cachePath, { 131 | encoding: 'utf-8', 132 | flag: 'r', 133 | }) 134 | ); 135 | } 136 | } 137 | 138 | async function setCache(fileName, data) { 139 | const tmpDir = path.join(__dirname, 'data'); 140 | const cachePath = path.join(tmpDir, `${fileName}.json`); 141 | 142 | try { 143 | if (!fs.existsSync(tmpDir)) { 144 | fs.mkdirSync(tmpDir); 145 | } 146 | 147 | fs.writeFileSync(cachePath, JSON.stringify(data, null, 2), 'utf-8'); 148 | 149 | return data; 150 | } catch (error) { 151 | console.error(error.toString()); 152 | } 153 | } 154 | 155 | module.exports = { 156 | addMemberToGroup, 157 | findMember, 158 | findMemberByEmail, 159 | findOrProvisionUser, 160 | removeMemberFromGroup, 161 | removeMemberFromWorkspace, 162 | getCache, 163 | setCache, 164 | }; 165 | -------------------------------------------------------------------------------- /examples/nm/split-group-datas.js: -------------------------------------------------------------------------------- 1 | const { getCache, setCache } = require('./shared'); 2 | 3 | (async () => { 4 | const data = await getCache('members-nm-groups'); 5 | const nm = data['nm']['found']; 6 | const legacy = data['legacy']['found']; 7 | 8 | await setCache('members-import-nm', nm); 9 | await setCache('members-import-legacy', legacy); 10 | })(); 11 | -------------------------------------------------------------------------------- /examples/nm/user-remove.js: -------------------------------------------------------------------------------- 1 | /** 2 | * DANGER: Completely remove a User from a Workspace! 3 | * 4 | * NOTE: does not delete Account (manual process) 5 | */ 6 | 7 | const { scim, yargs } = require('../shared/scim'); 8 | const { findMemberByEmail, removeMemberFromWorkspace } = require('./shared'); 9 | 10 | const argv = yargs 11 | .option('email', { 12 | alias: 'e', 13 | describe: "User's email address", 14 | }) 15 | .option('userId', { 16 | alias: 'u', 17 | describe: "User's Notion identifier", 18 | }).argv; 19 | 20 | (async function () { 21 | let userId = argv.userId; 22 | 23 | if (!(userId || argv.email)) { 24 | return console.log('Need either a userId or email'); 25 | } else if (argv.email) { 26 | const user = await findMemberByEmail(argv.email); 27 | userId = user.id; 28 | } 29 | 30 | if (!userId) { 31 | return console.log('Could not find user'); 32 | } 33 | 34 | await removeMemberFromWorkspace(userId); 35 | })(); 36 | -------------------------------------------------------------------------------- /examples/oura/oura.js: -------------------------------------------------------------------------------- 1 | const { default: axios } = require('axios'); 2 | const dotenv = require('dotenv'); 3 | const { Client } = require('@notionhq/client'); 4 | const yargs = require('yargs/yargs')(process.argv.slice(2)); 5 | 6 | dotenv.config(); 7 | 8 | // Set up a Notion client 9 | const auth = yargs.argv.notionApiToken || process.env.NOTION_API_TOKEN; 10 | const notion = new Client({ auth }); 11 | 12 | // Set up an Oura client 13 | const token = yargs.argv.ouraRingToken || process.env.OURA_RING_TOKEN; 14 | const oura = axios.create({ 15 | baseURL: 'https://api.ouraring.com/v2', 16 | headers: { 17 | Authorization: `Bearer ${token}`, 18 | }, 19 | }); 20 | 21 | module.exports = { 22 | notion, 23 | oura, 24 | yargs, 25 | }; 26 | -------------------------------------------------------------------------------- /examples/oura/sync.js: -------------------------------------------------------------------------------- 1 | const { parseISO, subDays } = require('date-fns'); 2 | const { formatInTimeZone } = require('date-fns-tz'); 3 | const { notion, oura, yargs } = require('./oura'); 4 | 5 | const argv = yargs.argv; 6 | 7 | // ID of our Journal database 8 | const databaseId = argv.databaseId || process.env.OURA_JOURNAL_DATABASE_ID; 9 | 10 | // Property for lookups and creation 11 | const nameProperty = argv.nameProperty || process.env.OURA_JOURNAL_NAME_PROP || 'Name'; 12 | const dateProperty = argv.dateProperty || process.env.OURA_JOURNAL_DATE_PROP || 'Date'; 13 | 14 | // Prefix entries when creating journal 15 | const namePrefix = argv.namePrefix || process.env.OURA_JOURNAL_NAME_PREFIX || 'Journal: '; 16 | 17 | // Date formats 18 | const dateTitleFormat = argv.dateTitleFormat || process.env.OURA_DATE_TITLE_FORMAT || 'MMM d, yyyy'; 19 | const dateIsoFormat = 'yyyy-MM-dd'; 20 | 21 | // Use date-fns to format our dates. Intl API is a nightmare. 22 | let date = new Date(); 23 | let timeZone = argv.timezone || process.env.OURA_TIMEZONE || 'America/Los_Angeles'; 24 | 25 | if (argv.date) { 26 | date = parseISO(argv.date); 27 | timeZone = 'UTC'; 28 | } 29 | 30 | const dateTitle = formatInTimeZone(date, timeZone, dateTitleFormat); 31 | const dateValue = formatInTimeZone(date, timeZone, dateIsoFormat); 32 | 33 | /** 34 | * Finds the most recent day's score for a type (Readiness, Sleep, Activity). 35 | * 36 | * We could sent through a single date for the range, but Activity seems to return 37 | * slightly different results that Readiness and Sleep (which return yesterday AND today) 38 | * whereas Activity has one result for yesterday. 39 | * 40 | * So perhaps you run this script twice a day to make sure you get Activity later! 41 | * 42 | * @param {String} type One of 'readiness', 'sleep', or 'activity' 43 | * 44 | * @returns {Number} Latest score by type 45 | */ 46 | async function fetchOuraScore(type) { 47 | // GET https://api.ouraring.com/v2/usercollection/daily_?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD 48 | 49 | const endDate = dateValue; 50 | const startDate = formatInTimeZone(subDays(date, 1), timeZone, dateIsoFormat); 51 | 52 | const uri = `usercollection/daily_${type}?start_date=${startDate}&end_date=${endDate}`; 53 | 54 | const { 55 | data: { data: entries }, 56 | } = await oura.get(uri); 57 | 58 | if (entries.length) { 59 | return Number(entries.slice(-1)[0].score); 60 | } 61 | } 62 | 63 | async function fetchOuraScores() { 64 | return await ['readiness', 'sleep', 'activity'].reduce(async (scores, type) => { 65 | const score = await fetchOuraScore(type); 66 | 67 | return { 68 | ...(await scores), 69 | [type]: score, 70 | }; 71 | }, {}); 72 | } 73 | 74 | async function fetchOrCreateJournal() { 75 | // Query journal by date 76 | let { 77 | results: [today], 78 | } = await notion.databases.query({ 79 | database_id: databaseId, 80 | filter: { 81 | property: dateProperty, 82 | date: { 83 | equals: dateValue, 84 | }, 85 | }, 86 | }); 87 | 88 | // No today? Create it. 89 | if (!today) { 90 | const properties = { 91 | [dateProperty]: { 92 | date: { 93 | start: dateValue, 94 | time_zone: null, 95 | }, 96 | }, 97 | }; 98 | 99 | today = await notion.pages.create({ 100 | parent: { 101 | type: 'database_id', 102 | database_id: databaseId, 103 | }, 104 | icon: { 105 | type: 'external', 106 | external: { url: `https://www.notion.so/icons/drafts_gray.svg` }, 107 | }, 108 | properties, 109 | }); 110 | } 111 | 112 | return today; 113 | } 114 | 115 | async function addOuraProperties({ parent: { database_id: databaseId }, properties }) { 116 | const requiredProps = ['Readiness', 'Sleep', 'Activity']; 117 | const newProps = requiredProps.reduce((props, prop) => { 118 | if (!properties.hasOwnProperty(prop)) { 119 | props[prop] = { number: {} }; 120 | } 121 | return props; 122 | }, {}); 123 | 124 | if (Object.keys(newProps).length) { 125 | await notion.databases.update({ 126 | database_id: databaseId, 127 | properties: newProps, 128 | }); 129 | } 130 | } 131 | 132 | function ouraProperty(value) { 133 | return { 134 | number: Number(value), 135 | }; 136 | } 137 | 138 | async function updateJournal(today, scores) { 139 | const { readiness, sleep, activity } = scores; 140 | 141 | const properties = { 142 | [nameProperty]: { 143 | title: [ 144 | { 145 | type: 'text', 146 | text: { 147 | content: `${namePrefix}${dateTitle}`, 148 | }, 149 | }, 150 | ], 151 | }, 152 | Readiness: ouraProperty(readiness), 153 | Sleep: ouraProperty(sleep), 154 | Activity: ouraProperty(activity), 155 | }; 156 | 157 | return await notion.pages.update({ 158 | page_id: today.id, 159 | properties, 160 | }); 161 | } 162 | 163 | (async function () { 164 | // Fetch our three Oura scores for Readiness, Sleep, and Activity 165 | const scores = await fetchOuraScores(); 166 | 167 | // Find or create today's journal entry 168 | const today = await fetchOrCreateJournal(); 169 | 170 | // Add number properties for Readiness, Sleep, and Activity (if they don't exist yet) 171 | await addOuraProperties(today); 172 | 173 | // Update today's journal with Oura statistics 174 | await updateJournal(today, scores); 175 | 176 | console.log(today.url); 177 | console.log(scores); 178 | })(); 179 | -------------------------------------------------------------------------------- /examples/pages/append-to-page/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Append a block to a page. 3 | * 4 | * NOTE: this example is broken intentionally as "to_do" blocks 5 | * raise an error in the API as of Sep 13, 2023. 6 | * 7 | * Arguments: 8 | * 9 | * --block-id: ID of the parent page to append to. 10 | */ 11 | 12 | const { notion, yargs } = require('../../shared'); 13 | const props = require('../../shared/props'); 14 | const { log } = require('../../shared/utils'); 15 | 16 | const blockId = 'c8a2020164d843f59ab23a59e5353ace'; 17 | const argv = yargs.default({ blockId }).argv; 18 | 19 | const params = { 20 | block_id: blockId, 21 | children: [ 22 | { 23 | to_do: { 24 | rich_text: [ 25 | { 26 | text: { 27 | content: 'Finish goals', 28 | }, 29 | }, 30 | ], 31 | }, 32 | }, 33 | ], 34 | }; 35 | 36 | (async () => { 37 | const block = await notion.blocks.children.append(params); 38 | 39 | log(block); 40 | })(); 41 | -------------------------------------------------------------------------------- /examples/pages/create-from-template/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Arguments: 3 | * 4 | * Note that this only copies over the template's properties. 5 | * 6 | * Body copying is in an open PR and there are API limitations 7 | * which prevent a full example. 8 | * 9 | * --template-id: ID of the template to create from 10 | */ 11 | 12 | const { yargs } = require('../../shared'); 13 | const { createFromTemplate } = require('../../shared/create-from-template'); 14 | const titledDate = require('../../shared/titled-date'); 15 | const { log } = require('../../shared/utils'); 16 | 17 | const templateId = '21d0fdf87ed64a938cd4d53245eea43e'; 18 | const argv = yargs.default({ templateId }).argv; 19 | 20 | (async () => { 21 | const page = await createFromTemplate(argv.templateId, titledDate('Entry')); 22 | 23 | log(page); 24 | })(); 25 | -------------------------------------------------------------------------------- /examples/pages/create-in-database/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Create a page inside a database. 3 | * 4 | * Arguments: 5 | * 6 | * --database-id: ID of the database to create in 7 | */ 8 | 9 | const { notion, yargs } = require('../../shared'); 10 | const props = require('../../shared/props'); 11 | const titledDate = require('../../shared/titled-date'); 12 | const { log } = require('../../shared/utils'); 13 | 14 | const databaseId = '688d410fd0e842a2ad399650d34842ba'; 15 | const argv = yargs.default({ databaseId }).argv; 16 | 17 | // The titledDate helper adds a title in the format of ": " 18 | // and also assigns the "Date" property with the same date. So this will create 19 | // a page called something like "Journal: Jan 13, 2023" 20 | const properties = titledDate('Journal'); 21 | 22 | const params = { 23 | parent: { 24 | type: 'database_id', 25 | database_id: argv.databaseId, 26 | }, 27 | icon: props.emoji('👨‍🚒'), 28 | properties, 29 | }; 30 | 31 | (async () => { 32 | const page = await notion.pages.create(params); 33 | 34 | log(page); 35 | })(); 36 | -------------------------------------------------------------------------------- /examples/pages/create-many/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Arguments: 3 | * 4 | * --template-id: ID of the template to create from 5 | * --rps-unit: number of ms per 1 request 6 | */ 7 | 8 | const { RateLimit } = require('async-sema'); 9 | const { notion, yargs } = require('../../shared'); 10 | const { createFromTemplate } = require('../../shared/create-from-template'); 11 | const titledDate = require('../../shared/titled-date'); 12 | const { log } = require('../../shared/utils'); 13 | 14 | const templateId = '21d0fdf87ed64a938cd4d53245eea43e'; 15 | const rpsUnit = 2000; // 1 request per 2000ms 16 | const argv = yargs.default({ 17 | rpsUnit, 18 | templateId, 19 | }).argv; 20 | 21 | // Writes to databases need a lot of time to settle, so we'll limit our requests 22 | // to 1 per 2 seconds. In real scenario, we'd want to push these to some sort of 23 | // background queuing system and allow them to be retried when they fail. 24 | const limit = RateLimit(1, { 25 | timeUnit: argv.rpsUnit, 26 | uniformDistribution: true, 27 | }); 28 | 29 | const startDate = new Date(2023, 0, 1); 30 | const endDate = new Date(2023, 0, 7); 31 | const dates = []; 32 | const entryPrefix = 'Journal'; 33 | 34 | for (let d = startDate; d <= endDate; d.setDate(d.getDate() + 1)) { 35 | dates.push(new Date(d)); 36 | } 37 | 38 | async function createPage(template, date) { 39 | process.stdout.write('.'); 40 | 41 | const properties = titledDate(entryPrefix, date); 42 | 43 | return await createFromTemplate(template, properties); 44 | } 45 | 46 | async function createPages(template, dates) { 47 | return await Promise.all( 48 | dates.map(async (date) => { 49 | await limit(); 50 | return await createPage(template, date); 51 | }) 52 | ); 53 | } 54 | 55 | (async () => { 56 | const template = await notion.pages.retrieve({ page_id: argv.templateId }); 57 | const pages = await createPages(template, dates); 58 | 59 | log(pages); 60 | })(); 61 | -------------------------------------------------------------------------------- /examples/pages/create-with-external-icon/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Arguments: 3 | * 4 | * --database-id: ID of the database to create in 5 | */ 6 | 7 | const { notion, yargs } = require('../../shared'); 8 | const props = require('../../shared/props'); 9 | const titledDate = require('../../shared/titled-date'); 10 | const { log } = require('../../shared/utils'); 11 | 12 | const databaseId = '688d410fd0e842a2ad399650d34842ba'; 13 | const argv = yargs.default({ databaseId }).argv; 14 | 15 | const params = { 16 | parent: { 17 | database_id: argv.databaseId, 18 | }, 19 | icon: { 20 | type: 'external', 21 | external: { 22 | url: 'https://www.notion.so/cdn-cgi/image/format=auto,width=640,quality=100/front-static/pages/product/home-page-hero-refreshed-v3.png', 23 | }, 24 | }, 25 | properties: { 26 | Name: { 27 | title: [ 28 | { 29 | text: { 30 | content: 'External Icon', 31 | }, 32 | }, 33 | ], 34 | }, 35 | }, 36 | }; 37 | 38 | (async () => { 39 | const page = await notion.pages.create(params); 40 | 41 | log(page); 42 | })(); 43 | -------------------------------------------------------------------------------- /examples/pages/create/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Create a page inside another page 3 | * 4 | * Arguments: 5 | * 6 | * --page-id: ID of the parent page to create the page inside 7 | */ 8 | 9 | const { notion, yargs } = require('../../shared'); 10 | const props = require('../../shared/props'); 11 | const { log } = require('../../shared/utils'); 12 | 13 | const pageId = '6027a8c8749a4eb9a9bc9bc2714c0d08'; 14 | const argv = yargs.default({ pageId }).argv; 15 | 16 | const params = { 17 | parent: { 18 | type: 'page_id', 19 | page_id: argv.pageId, 20 | }, 21 | icon: props.emoji('📄'), 22 | properties: { 23 | title: props.pageTitle('Hello, world!'), 24 | }, 25 | }; 26 | 27 | (async () => { 28 | const page = await notion.pages.create(params); 29 | 30 | log(page); 31 | })(); 32 | -------------------------------------------------------------------------------- /examples/pages/retrieve/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Arguments: 3 | * 4 | * --page-id: ID of the page to fetch 5 | */ 6 | 7 | const { notion, yargs } = require('../../shared'); 8 | const { log } = require('../../shared/utils'); 9 | 10 | const pageId = '6027a8c8749a4eb9a9bc9bc2714c0d08'; 11 | const argv = yargs.default({ pageId }).argv; 12 | 13 | (async () => { 14 | const page = await notion.pages.retrieve({ 15 | page_id: argv.pageId, 16 | }); 17 | 18 | log(page); 19 | })(); 20 | -------------------------------------------------------------------------------- /examples/people/list/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Arguments: 3 | * 4 | * --page-size: 0-100, number of pages to fetch 5 | * --start-cursor: where to start in the results 6 | */ 7 | 8 | const { notion, yargs } = require('../../shared'); 9 | const { log } = require('../../shared/utils'); 10 | 11 | const argv = yargs.default({ 12 | pageSize: 100, 13 | }).argv; 14 | 15 | (async () => { 16 | // NOTE: the Notion API only returns Members and Bots (no Guests!! :( ) 17 | const users = await notion.users.list({ 18 | page_size: argv.pageSize, 19 | start_cursor: argv.startCursor, 20 | }); 21 | 22 | log(users); 23 | })(); 24 | -------------------------------------------------------------------------------- /examples/people/me/index.js: -------------------------------------------------------------------------------- 1 | const { notion } = require('../../shared'); 2 | const { log } = require('../../shared/utils'); 3 | 4 | (async () => { 5 | const user = await notion.users.me(); 6 | 7 | log(user); 8 | })(); 9 | -------------------------------------------------------------------------------- /examples/people/retrieve/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Arguments: 3 | * 4 | * --user-id: ID of the user to retrieve 5 | */ 6 | 7 | const { notion, yargs } = require('../../shared'); 8 | const { log } = require('../../shared/utils'); 9 | 10 | const userId = '333cf6eb-705a-4d62-ac98-a99b7c99f0ce'; 11 | 12 | const argv = yargs.default({ 13 | userId, 14 | }).argv; 15 | 16 | (async () => { 17 | const user = await notion.users.retrieve({ 18 | user_id: argv.userId, 19 | }); 20 | 21 | log(user); 22 | })(); 23 | -------------------------------------------------------------------------------- /examples/properties/add-advanced.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Add standard "advanced" properties to a database. 3 | * 4 | * Arguments: 5 | * 6 | * --database-id: ID of the property to add the properties to 7 | * --[no-]watched-by: whether to create a "Watched by" Person property 8 | * (default or --watched-by) or not (--no-watched-by) 9 | */ 10 | 11 | const { notion, yargs } = require('../shared'); 12 | const { log } = require('../shared/utils'); 13 | 14 | const databaseId = '5bdcff6258b246788536d5c642b2fe55'; 15 | const argv = yargs.boolean('watched-by').default({ databaseId, watchedBy: true }).argv; 16 | 17 | (async () => { 18 | let properties = argv.watchedBy ? { 'Watched by': { people: {} } } : {}; 19 | 20 | properties = { 21 | ...properties, 22 | 'Created by': { created_by: {} }, 23 | 'Created at': { created_time: {} }, 24 | 'Updated by': { last_edited_by: {} }, 25 | 'Updated at': { last_edited_time: {} }, 26 | }; 27 | 28 | const params = { 29 | database_id: argv.databaseId, 30 | properties, 31 | }; 32 | 33 | const database = await notion.databases.update(params); 34 | 35 | log(database); 36 | })(); 37 | -------------------------------------------------------------------------------- /examples/properties/add-property.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Add a simple property to a database 3 | * 4 | * Arguments: 5 | * 6 | * --database-id: ID of the property to add to 7 | * --name: Name of the property to add 8 | * --type: Type of the property to add 9 | */ 10 | 11 | const { notion, yargs } = require('../shared'); 12 | const { log } = require('../shared/utils'); 13 | 14 | const databaseId = '5bdcff6258b246788536d5c642b2fe55'; 15 | const name = 'Watched by'; 16 | const type = 'people'; 17 | const argv = yargs.default({ databaseId, name, type }).argv; 18 | 19 | (async () => { 20 | const params = { 21 | database_id: argv.databaseId, 22 | properties: { 23 | [argv.name]: { 24 | type, 25 | [argv.type]: {}, 26 | }, 27 | }, 28 | }; 29 | 30 | const database = await notion.databases.update(params); 31 | 32 | log(database); 33 | })(); 34 | -------------------------------------------------------------------------------- /examples/properties/google-drive-property.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Retrieve a Google Drive property _value_ from a database. 3 | * 4 | * Google Drive properties are Relations under the hood and 5 | * can be fetched, allowing you to access the underlying link 6 | * to the Google Drive file, but cannot be created or attached 7 | * via the API at this time (as far as I can understand). 8 | * 9 | * It's not possible to extrapolate what properties on a database 10 | * might be Google Drive properties as they only are reported as 11 | * "relation" types, so you would have to infer Google Drive properties 12 | * based on their titles or know the actual property ID of the property. 13 | * 14 | * Arguments: 15 | * 16 | * --page-id: ID of the page the property exists in 17 | * --prop-id: ID of the property to fetch 18 | */ 19 | 20 | const { notion, yargs } = require('../shared'); 21 | const { log } = require('../shared/utils'); 22 | 23 | const pageId = '1a51c1cce3f380c688b5ecdcc0a09f75'; 24 | const propId = 's%60%3Ev'; 25 | const argv = yargs.default({ pageId, propId }).argv; 26 | 27 | async function fetchFiles(ids) { 28 | return await Promise.all( 29 | ids.map(async (id) => { 30 | return await notion.pages.retrieve({ 31 | page_id: id, 32 | }); 33 | }) 34 | ); 35 | } 36 | 37 | (async () => { 38 | const { results } = await notion.pages.properties.retrieve({ 39 | page_id: argv.pageId, 40 | property_id: argv.propId, 41 | }); 42 | 43 | const fileIds = results.map((file) => file.relation.id); 44 | const files = await fetchFiles(fileIds); 45 | 46 | log(files); 47 | })(); 48 | -------------------------------------------------------------------------------- /examples/properties/retrieve.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Retrieve a property _value_ from a database. 3 | * 4 | * Required if you want to get accurate data from Rollups via the API. 5 | * 6 | * Arguments: 7 | * 8 | * --prop-id: ID of the property to fetch 9 | */ 10 | 11 | const { notion, yargs } = require('../shared'); 12 | const { log } = require('../shared/utils'); 13 | 14 | const pageId = 'a2d67b9cb48e4b2aaca6026d8d577dfd'; 15 | const propId = 'uy%7Db'; 16 | const argv = yargs.default({ pageId, propId }).argv; 17 | 18 | (async () => { 19 | const prop = await notion.pages.properties.retrieve({ 20 | page_id: argv.pageId, 21 | property_id: argv.propId, 22 | }); 23 | 24 | log(prop); 25 | })(); 26 | -------------------------------------------------------------------------------- /examples/scim/groups/create.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Create a Notion Group 3 | * 4 | * --name: Name of the Group to create 5 | */ 6 | 7 | const { scim, yargs, SCIM_SCHEMA_GROUP } = require('../../shared/scim'); 8 | const { log } = require('../../shared/utils'); 9 | 10 | const name = 'Group Name'; 11 | const argv = yargs.default({ name }).argv; 12 | 13 | (async () => { 14 | // POST https://api.notion.com/scim/v2/Groups 15 | 16 | const { data: group } = await scim.post('Groups', { 17 | schemas: [SCIM_SCHEMA_GROUP], 18 | displayName: argv.name, 19 | }); 20 | 21 | log(group); 22 | })(); 23 | -------------------------------------------------------------------------------- /examples/scim/groups/delete.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Delete a Notion Group 3 | * 4 | * --group-id: ID of Notion Group to delete 5 | */ 6 | 7 | const { scim, yargs } = require('../../shared/scim'); 8 | 9 | const groupId = '72448843-6e69-4050-9488-fe0b28e6c970'; 10 | const argv = yargs.default({ groupId }).argv; 11 | 12 | (async () => { 13 | // DELETE https://api.notion.com/scim/v2/Groups/{id} 14 | 15 | try { 16 | const { status, statusText } = await scim.delete(`Groups/${argv.groupId}`); 17 | 18 | console.log(`${status}: ${statusText}`); 19 | } catch ({ response: { status, statusText } }) { 20 | console.log(`${status}: ${statusText}`); 21 | } 22 | })(); 23 | -------------------------------------------------------------------------------- /examples/scim/groups/get.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fetch a Notion Group 3 | * 4 | * --group-id: ID of Group to fetch 5 | */ 6 | 7 | const { scim, yargs } = require('../../shared/scim'); 8 | const { log } = require('../../shared/utils'); 9 | 10 | const groupId = '70158620-4985-4b86-b08e-95657b6d2edf'; 11 | const argv = yargs.default({ groupId }).argv; 12 | 13 | (async () => { 14 | // GET https://api.notion.com/scim/v2/Groups/{id} 15 | 16 | const { data: group } = await scim.get(`Groups/${argv.groupId}`); 17 | 18 | log(group); 19 | })(); 20 | -------------------------------------------------------------------------------- /examples/scim/groups/list.js: -------------------------------------------------------------------------------- 1 | /** 2 | * List Notion Groups 3 | * 4 | * --count: 0-100, number of groups to fetch 5 | * --start-index: where to start in the results 6 | * --filter: filter group by displayName 7 | * --[no-]sort: sort the results (funky when paginating) 8 | */ 9 | 10 | const _ = require('lodash'); 11 | const { scim, yargs } = require('../../shared/scim'); 12 | const { log } = require('../../shared/utils'); 13 | 14 | const argv = yargs.boolean('sort').default({ 15 | count: 100, 16 | startIndex: 1, 17 | }).argv; 18 | 19 | (async () => { 20 | // GET https://api.notion.com/scim/v2/Groups 21 | 22 | const params = { 23 | count: argv.count, 24 | startIndex: argv.startIndex, 25 | }; 26 | 27 | if (argv.filter) { 28 | // SEE: https://ldapwiki.com/wiki/SCIM%20Filtering 29 | params.filter = `displayName eq "${argv.filter}"`; 30 | } 31 | 32 | try { 33 | let { 34 | data: { Resources: groups }, 35 | } = await scim.get('Groups', { params }); 36 | 37 | // Formats groups into simple format showing group name, id, 38 | // and a count showing the number of members in the group. 39 | 40 | groups = _.reduce( 41 | groups, 42 | (all, group) => { 43 | all.push({ 44 | displayName: group.displayName, 45 | id: group.id, 46 | memberCount: group.members.length || 0, 47 | index: all.length + 1, 48 | }); 49 | 50 | return all; 51 | }, 52 | [] 53 | ); 54 | 55 | // Order by name if desired 56 | if (argv.sort) { 57 | groups = _.orderBy(groups, 'displayName'); 58 | } 59 | 60 | log(groups); 61 | } catch (e) { 62 | console.log(e); 63 | } 64 | })(); 65 | -------------------------------------------------------------------------------- /examples/scim/groups/member-add.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Add a User to a Group 3 | * 4 | * --group-id: ID of Group to add to 5 | * --user-id: ID of User to add 6 | */ 7 | 8 | const { scim, yargs, SCIM_SCHEMA_PATCH_OP } = require('../../shared/scim'); 9 | 10 | const groupId = '0332a96e-0a56-4454-ab7b-f90546bc0e79'; 11 | const userId = '333cf6eb-705a-4d62-ac98-a99b7c99f0ce'; 12 | const argv = yargs.default({ groupId, userId }).argv; 13 | 14 | (async () => { 15 | // PATCH https://api.notion.com/scim/v2/Groups/{id} 16 | 17 | try { 18 | const { status, statusText } = await scim.patch(`Groups/${argv.groupId}`, { 19 | schemas: [SCIM_SCHEMA_PATCH_OP], 20 | Operations: [ 21 | { 22 | op: 'Add', 23 | path: 'members', 24 | value: [ 25 | { 26 | value: argv.userId, 27 | }, 28 | ], 29 | }, 30 | ], 31 | }); 32 | 33 | console.log(`${status}: ${statusText}`); 34 | } catch (e) { 35 | console.log('Error', e); 36 | } 37 | })(); 38 | -------------------------------------------------------------------------------- /examples/scim/groups/member-remove.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Remove a User from a Group 3 | * 4 | * --group-id: ID of Group to remove from 5 | * --user-id: ID of User to remove 6 | */ 7 | 8 | const { scim, yargs, SCIM_SCHEMA_PATCH_OP } = require('../../shared/scim'); 9 | 10 | const groupId = '0332a96e-0a56-4454-ab7b-f90546bc0e79'; 11 | const userId = '333cf6eb-705a-4d62-ac98-a99b7c99f0ce'; 12 | const argv = yargs.default({ groupId, userId }).argv; 13 | 14 | (async () => { 15 | // PATCH https://api.notion.com/scim/v2/Groups/{id} 16 | 17 | try { 18 | const { status, statusText } = await scim.patch(`Groups/${argv.groupId}`, { 19 | schemas: [SCIM_SCHEMA_PATCH_OP], 20 | Operations: [ 21 | { 22 | op: 'Remove', 23 | path: 'members', 24 | value: [ 25 | { 26 | value: argv.userId, 27 | }, 28 | ], 29 | }, 30 | ], 31 | }); 32 | 33 | console.log(`${status}: ${statusText}`); 34 | } catch (e) { 35 | console.log('Error', e); 36 | } 37 | })(); 38 | -------------------------------------------------------------------------------- /examples/scim/groups/schema.js: -------------------------------------------------------------------------------- 1 | const find = require('lodash/find'); 2 | const { scim } = require('../../shared/scim'); 3 | const { log } = require('../../shared/utils'); 4 | 5 | (async () => { 6 | // GET https://api.notion.com/scim/v2/ResourceTypes 7 | 8 | const { data } = await scim.get('ResourceTypes'); 9 | const schema = find(data, { id: 'Group' }); 10 | 11 | log(schema); 12 | })(); 13 | -------------------------------------------------------------------------------- /examples/scim/groups/update.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Update a Notion Group 3 | * 4 | * --group-id: ID of Group to update 5 | */ 6 | 7 | const { scim, yargs, SCIM_SCHEMA_GROUP } = require('../../shared/scim'); 8 | 9 | const groupId = '0332a96e-0a56-4454-ab7b-f90546bc0e79'; 10 | const argv = yargs.default({ groupId }).argv; 11 | 12 | (async () => { 13 | // PUT https://api.notion.com/scim/v2/Groups/{id} 14 | 15 | try { 16 | const { status, statusText } = await scim.put(`Groups/${argv.groupId}`, { 17 | schemas: [SCIM_SCHEMA_GROUP], 18 | displayName: 'Group Name Updated', 19 | }); 20 | 21 | console.log(`${status}: ${statusText}`); 22 | } catch (e) { 23 | console.log(e); 24 | } 25 | })(); 26 | -------------------------------------------------------------------------------- /examples/scim/service-provider.js: -------------------------------------------------------------------------------- 1 | const { scim } = require('../shared/scim'); 2 | 3 | (async () => { 4 | // GET https://api.notion.com/scim/v2/ServiceProviderConfig 5 | 6 | const { data } = await scim.get('ServiceProviderConfig'); 7 | 8 | console.log(data); 9 | })(); 10 | -------------------------------------------------------------------------------- /examples/scim/users/create.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Provision a Notion User 3 | * 4 | * --email, -e: User's email 5 | * --name, -n: Full name of the User 6 | */ 7 | 8 | const { scim, yargs, SCIM_SCHEMA_USER } = require('../../shared/scim'); 9 | 10 | const argv = yargs 11 | .option('email', { 12 | alias: 'e', 13 | describe: "User's email address", 14 | }) 15 | .option('name', { 16 | alias: 'n', 17 | describe: "User's full name", 18 | }) 19 | .demandOption('email').argv; 20 | 21 | (async () => { 22 | // POST https://api.notion.com/scim/v2/Users 23 | 24 | try { 25 | const args = { 26 | userName: argv.email, 27 | }; 28 | 29 | if (argv.name) { 30 | args.name = { 31 | formatted: argv.name, 32 | }; 33 | } 34 | 35 | // Note this will raise a 409 error if user already exists 36 | const { data: user } = await scim.post('Users', { 37 | schemas: [SCIM_SCHEMA_USER], 38 | ...args, 39 | }); 40 | 41 | console.log(user); 42 | } catch (e) { 43 | console.log(e); 44 | } 45 | })(); 46 | -------------------------------------------------------------------------------- /examples/scim/users/delete.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Remove a User from a Workspace 3 | * 4 | * NOTE: does not delete Account (manual process) 5 | * 6 | * --used-id, -u: ID of User to remove 7 | */ 8 | 9 | const { scim, yargs } = require('../../shared/scim'); 10 | 11 | const userId = 'ebd6dbe5-4f02-4a8a-bb77-5f3f4b1f9a48'; 12 | const argv = yargs 13 | .option('u', { 14 | alias: 'user-id', 15 | describe: "User's ID", 16 | }) 17 | .default({ userId }).argv; 18 | 19 | (async function () { 20 | // DELETE https://api.notion.com/scim/v2/Users/{id} 21 | 22 | try { 23 | const { status, statusText } = await scim.delete(`Users/${argv.userId}`); 24 | 25 | console.log(`${status}: ${statusText}`); 26 | } catch ({ response: { status, statusText } }) { 27 | console.log(`${status}: ${statusText}`); 28 | } 29 | })(); 30 | -------------------------------------------------------------------------------- /examples/scim/users/get.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fetch a Notion User 3 | * 4 | * --user-id, -u: ID of User to fetch 5 | */ 6 | 7 | const { scim, yargs } = require('../../shared/scim'); 8 | 9 | const userId = '333cf6eb-705a-4d62-ac98-a99b7c99f0ce'; 10 | const argv = yargs 11 | .option('u', { 12 | alias: 'user-id', 13 | type: 'string', 14 | }) 15 | .default({ userId }).argv; 16 | 17 | (async () => { 18 | // GET https://api.notion.com/scim/v2/Users/{id} 19 | 20 | const { data: user } = await scim.get(`Users/${argv.userId}`); 21 | 22 | console.log(user); 23 | })(); 24 | -------------------------------------------------------------------------------- /examples/scim/users/list.js: -------------------------------------------------------------------------------- 1 | /** 2 | * List Notion Users 3 | * 4 | * --count: 0-100, number of Users to fetch 5 | * --start-index: Where to start in the results 6 | * --email, -e: Filter User by email 7 | * --[no-]sort: Sort the results (funky when paginating) 8 | */ 9 | 10 | const _ = require('lodash'); 11 | const { scim, yargs } = require('../../shared/scim'); 12 | 13 | const argv = yargs.boolean('sort').option('e', { alias: 'email', type: 'string' }).default({ 14 | count: 100, 15 | startIndex: 1, 16 | }).argv; 17 | 18 | (async () => { 19 | // GET https://api.notion.com/scim/v2/Users 20 | 21 | const params = { 22 | count: argv.count, 23 | startIndex: argv.startIndex, 24 | }; 25 | 26 | if (argv.email) { 27 | // SEE: https://ldapwiki.com/wiki/Wiki.jsp?page=SCIM%20Filtering 28 | params.filter = `email eq "${argv.email}"`; 29 | } 30 | 31 | try { 32 | let data = await scim.get('Users', { params }); 33 | let { 34 | data: { Resources: users }, 35 | } = data; 36 | 37 | users = _.reduce( 38 | users, 39 | (all, user) => { 40 | all.push({ 41 | active: user.active, 42 | displayName: user.displayName, 43 | email: _.find(user.emails, { primary: true }).value, 44 | id: user.id, 45 | index: all.length + 1, 46 | }); 47 | 48 | return all; 49 | }, 50 | [] 51 | ); 52 | 53 | // Order by name 54 | if (argv.sort) { 55 | users = _.orderBy(users, 'email'); 56 | } 57 | 58 | console.log(users); 59 | } catch (e) { 60 | console.log(e); 61 | } 62 | })(); 63 | -------------------------------------------------------------------------------- /examples/scim/users/schema.js: -------------------------------------------------------------------------------- 1 | const find = require('lodash/find'); 2 | const { scim } = require('../../shared/scim'); 3 | const { log } = require('../../shared/utils'); 4 | 5 | (async () => { 6 | // GET https://api.notion.com/scim/v2/ResourceTypes 7 | 8 | const { data } = await scim.get('ResourceTypes'); 9 | const schema = find(data, { id: 'User' }); 10 | 11 | log(schema); 12 | })(); 13 | -------------------------------------------------------------------------------- /examples/scim/users/update-patch.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Update a User via PATCH method 3 | * 4 | * NOTE: You can only update a member’s profile information if you have verified 5 | * ownership of the user’s email domain (this is typically the same as the 6 | * email domains you have configured for SAML Single Sign-On with Notion). 7 | * 8 | * SEE: https://www.notion.so/help/provision-users-and-groups-with-scim 9 | * 10 | * NOTE: this raises a 500 error if an email is provided and the email 11 | * already exists in Notion as an account. This differs from the 12 | * 409 status raised by the POST API. 13 | * 14 | * --email, e: Email of User 15 | * --user-id, u: ID of User to update 16 | */ 17 | 18 | const { scim, yargs, SCIM_SCHEMA_PATCH_OP } = require('../../shared/scim'); 19 | 20 | const userId = '1c401a11-086d-4814-b450-b794d5d75085'; 21 | const name = 'Updated Name'; 22 | const argv = yargs 23 | .options({ 24 | e: { 25 | alias: 'email', 26 | string: true, 27 | }, 28 | n: { 29 | alias: 'name', 30 | string: true, 31 | }, 32 | u: { 33 | alias: 'user-id', 34 | string: true, 35 | }, 36 | }) 37 | .default({ name, userId }).argv; 38 | 39 | (async () => { 40 | // PATCH https://api.notion.com/scim/v2/Users/{id} 41 | 42 | try { 43 | const { data: user } = await scim.patch(`Users/${argv.userId}`, { 44 | schemas: [SCIM_SCHEMA_PATCH_OP], 45 | Operations: [ 46 | { 47 | op: 'Replace', 48 | path: 'userName', 49 | value: argv.email, 50 | }, 51 | ], 52 | }); 53 | 54 | console.log(user); 55 | } catch (e) { 56 | console.log('Error', e); 57 | } 58 | })(); 59 | -------------------------------------------------------------------------------- /examples/scim/users/update-put.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Update a User via PUT method 3 | * 4 | * NOTE: You can only update a member’s profile information if you have verified 5 | * ownership of the user’s email domain (this is typically the same as the 6 | * email domains you have configured for SAML Single Sign-On with Notion). 7 | * 8 | * SEE: https://www.notion.so/help/provision-users-and-groups-with-scim 9 | * 10 | * NOTE: this raises a 500 error if an email is provided and the email 11 | * already exists in Notion as an account. This differs from the 12 | * 409 status raised by the POST API. 13 | * 14 | * --email, e: Email of User 15 | * --name, n: Name of User 16 | * --user-id, u: ID of User to update 17 | */ 18 | 19 | const { scim, yargs, SCIM_SCHEMA_USER } = require('../../shared/scim'); 20 | 21 | const userId = '1c401a11-086d-4814-b450-b794d5d75085'; 22 | const name = 'Updated Name'; 23 | const argv = yargs 24 | .options({ 25 | e: { 26 | alias: 'email', 27 | string: true, 28 | }, 29 | n: { 30 | alias: 'name', 31 | string: true, 32 | }, 33 | u: { 34 | alias: 'user-id', 35 | string: true, 36 | }, 37 | }) 38 | .default({ name, userId }).argv; 39 | 40 | (async () => { 41 | // PUT https://api.notion.com/scim/v2/Users/{id} 42 | 43 | const args = { 44 | name: { 45 | formatted: argv.name, 46 | }, 47 | }; 48 | 49 | if (argv.email) { 50 | args.userName = argv.email; 51 | } 52 | 53 | try { 54 | const { data: user } = await scim.put(`Users/${argv.userId}`, { 55 | schemas: [SCIM_SCHEMA_USER], 56 | ...args, 57 | }); 58 | 59 | console.log(user); 60 | } catch (e) { 61 | console.log('Error', e); 62 | } 63 | })(); 64 | -------------------------------------------------------------------------------- /examples/search/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Search Notion space by query. 3 | * 4 | * This one specifically is searching for databases by name. 5 | * 6 | * Note that your integration must be added to the databases and pages 7 | * you wish to make accessible via the API. 8 | * 9 | * Arguments: 10 | * 11 | * --query: search string 12 | * --page-size: how many to fetch per page 13 | */ 14 | 15 | const { notion, yargs } = require('../shared'); 16 | const { log } = require('../shared/utils'); 17 | const orderBy = require('lodash/orderBy'); 18 | 19 | const argv = yargs.default({ 20 | query: '', 21 | pageSize: 100, 22 | }).argv; 23 | 24 | (async () => { 25 | const response = await notion.search({ 26 | query: argv.query, 27 | filter: { 28 | property: 'object', 29 | value: 'database', 30 | }, 31 | page_size: argv.pageSize, 32 | }); 33 | 34 | let databases = response.results.reduce((prev, curr) => { 35 | prev.push({ 36 | id: curr.id, 37 | title: curr.title[0].plain_text, 38 | url: curr.url, 39 | }); 40 | 41 | return prev; 42 | }, []); 43 | 44 | databases = orderBy(databases, 'title'); 45 | 46 | log(databases); 47 | })(); 48 | -------------------------------------------------------------------------------- /examples/shared/circle.js: -------------------------------------------------------------------------------- 1 | const dotenv = require('dotenv'); 2 | const { default: axios } = require('axios'); 3 | const yargs = require('yargs/yargs')(process.argv.slice(2)); 4 | 5 | const CIRCLE_BASE_URI = 'https://app.circle.so/api/v1/'; 6 | const CIRCLE_COMMUNITY_ID = 493; 7 | const CIRCLE_SPACE_GROUP_IDS = [ 8 | 18581, // Welcome 9 | 10249, // Notion Mastery 10 | 915, // Share 11 | ]; 12 | 13 | dotenv.config(); 14 | 15 | const token = yargs.argv.circleToken || process.env.CIRCLE_API_TOKEN; 16 | const communityId = 17 | yargs.argv.communityId || process.env.CIRCLE_COMMUNITY_ID || CIRCLE_COMMUNITY_ID; 18 | 19 | const circle = axios.create({ 20 | baseURL: CIRCLE_BASE_URI, 21 | headers: { 22 | Authorization: `Token ${token}`, 23 | }, 24 | }); 25 | 26 | async function findCircleMember(email) { 27 | // GET https://app.circle.so/api/v1/community_members/search 28 | 29 | const { data } = await circle.get('community_members/search', { 30 | params: { 31 | community_id: communityId, 32 | email, 33 | }, 34 | }); 35 | 36 | if (data.success === false) { 37 | return false; 38 | } 39 | 40 | return data; 41 | } 42 | 43 | async function addCircleMember(email, name, space_group_ids = CIRCLE_SPACE_GROUP_IDS) { 44 | // POST https://app.circle.so/api/v1/community_members 45 | 46 | const params = { 47 | community_id: communityId, 48 | name, 49 | email, 50 | space_group_ids, 51 | }; 52 | 53 | const { data } = await circle.post('community_members', params); 54 | 55 | if (data.success === false) { 56 | return false; 57 | } 58 | 59 | return data; 60 | } 61 | 62 | async function removeCircleMember(email) { 63 | // DELETE https://app.circle.so/api/v1/community_members 64 | const { data } = await circle.delete('community_members', { 65 | params: { 66 | community_id: communityId, 67 | email, 68 | }, 69 | }); 70 | 71 | return data.success; 72 | } 73 | 74 | async function findAndRemoveCircleMember(email) { 75 | const circleMember = await findCircleMember(email); 76 | 77 | if (circleMember) { 78 | console.log(`CIRCLE: Removing circle member <${email}>`); 79 | 80 | await removeCircleMember(email); 81 | } else { 82 | console.log(`CIRCLE: No circle member for <${email}>`); 83 | } 84 | } 85 | 86 | module.exports = { 87 | CIRCLE_BASE_URI, 88 | CIRCLE_COMMUNITY_ID, 89 | circle, 90 | addCircleMember, 91 | findAndRemoveCircleMember, 92 | findCircleMember, 93 | removeCircleMember, 94 | }; 95 | -------------------------------------------------------------------------------- /examples/shared/convertkit.js: -------------------------------------------------------------------------------- 1 | const dotenv = require('dotenv'); 2 | const { default: axios } = require('axios'); 3 | const yargs = require('yargs/yargs')(process.argv.slice(2)); 4 | 5 | const CK_BASE_URI = 'https://api.convertkit.com/v3/'; 6 | const CK_TAG_MEMBERSHIP_EXPIRED = 4461817; 7 | 8 | dotenv.config(); 9 | 10 | const api_token = yargs.argv.ckToken || process.env.CONVERTKIT_API_TOKEN; 11 | const api_secret = yargs.argv.ckSecret || process.env.CONVERTKIT_API_SECRET; 12 | 13 | const ck = axios.create({ 14 | baseURL: CK_BASE_URI, 15 | }); 16 | 17 | ck.interceptors.request.use((config) => { 18 | // use config.params if it has been set 19 | config.params = config.params || {}; 20 | // add any client instance specific params to config 21 | config.params['api_secret'] = api_secret; 22 | return config; 23 | }); 24 | 25 | async function findConvertkitSubscriber(email) { 26 | const { 27 | data: { 28 | subscribers: [subscriber], 29 | }, 30 | } = await ck.get('subscribers', { 31 | params: { 32 | email_address: email, 33 | }, 34 | }); 35 | 36 | return subscriber; 37 | } 38 | 39 | async function addConvertkitTag(email, tagId = CK_TAG_MEMBERSHIP_EXPIRED) { 40 | const tag = await ck.post(`tags/${tagId}/subscribe`, { 41 | email, 42 | }); 43 | 44 | return tag; 45 | } 46 | 47 | async function removeConvertkitTag(email, tagId = CK_TAG_MEMBERSHIP_EXPIRED) { 48 | const subscriber = await findConvertkitSubscriber(email); 49 | 50 | if (subscriber) { 51 | return await ck.delete(`subscribers/${subscriber.id}/tags/${tagId}`); 52 | } 53 | } 54 | 55 | async function findAndTagConvertkitSubscriber(email, tag = CK_TAG_MEMBERSHIP_EXPIRED) { 56 | const subscriber = await findConvertkitSubscriber(email); 57 | 58 | if (subscriber) { 59 | console.log(`CK: Tagging subscriber <${email}>`); 60 | 61 | await addConvertkitTag(email, tag); 62 | } else { 63 | console.log(`CK: No subscriber for <${email}>`); 64 | } 65 | } 66 | 67 | module.exports = { 68 | ck, 69 | findAndTagConvertkitSubscriber, 70 | findConvertkitSubscriber, 71 | addConvertkitTag, 72 | removeConvertkitTag, 73 | }; 74 | -------------------------------------------------------------------------------- /examples/shared/create-from-template.js: -------------------------------------------------------------------------------- 1 | const { notion } = require('./index'); 2 | const _ = require('lodash'); 3 | 4 | // Properties we do not want to try to copy from template 5 | const INVALID_PROP_TYPES = [ 6 | 'created_by', 7 | 'created_time', 8 | 'last_edited_by', 9 | 'last_edited_time', 10 | 'formula', 11 | 'rollup', 12 | 'title', 13 | ]; 14 | 15 | function getTemplateProperties(properties, invalid_types = INVALID_PROP_TYPES) { 16 | return _.reduce( 17 | _.pickBy(properties, (prop) => { 18 | return !invalid_types.includes(prop.type); 19 | }), 20 | (props, prop, name) => { 21 | props[name] = _.pick(prop, [prop.type]); 22 | return props; 23 | }, 24 | {} 25 | ); 26 | } 27 | 28 | async function createFromTemplate(template, properties) { 29 | if (typeof template === 'string') { 30 | template = await notion.pages.retrieve({ page_id: template }); 31 | } 32 | 33 | const templateProperties = getTemplateProperties(template.properties); 34 | properties = _.assign(templateProperties, properties); 35 | 36 | const params = { 37 | parent: { database_id: template.parent.database_id }, 38 | icon: template.icon, 39 | properties, 40 | }; 41 | 42 | return await notion.pages.create(params); 43 | } 44 | 45 | module.exports = { 46 | createFromTemplate, 47 | getTemplateProperties, 48 | }; 49 | -------------------------------------------------------------------------------- /examples/shared/fetch-pages.js: -------------------------------------------------------------------------------- 1 | const { notion } = require('.'); 2 | const { RateLimit } = require('async-sema'); 3 | 4 | const rateLimiter = RateLimit(1, { 5 | timeUnit: 2000, 6 | uniformDistribution: true, 7 | }); 8 | 9 | async function fetchPages(databaseId, query = null, startCursor = undefined, pageSize = 100) { 10 | if (startCursor) { 11 | console.log(`Fetching pages starting at ${startCursor}`); 12 | } 13 | 14 | let params = { 15 | database_id: databaseId, 16 | page_size: pageSize, 17 | start_cursor: startCursor, 18 | }; 19 | 20 | if (query) { 21 | params = { ...params, ...query }; 22 | } 23 | 24 | return await notion.databases.query(params); 25 | } 26 | 27 | async function fetchAllPages(databaseId, query = undefined, limit = rateLimiter) { 28 | let pages = []; 29 | let hasMore = true; 30 | let startCursor = undefined; 31 | 32 | while (hasMore) { 33 | await limit(); 34 | 35 | const response = await fetchPages(databaseId, query, startCursor); 36 | 37 | pages = pages.concat(response.results); 38 | 39 | hasMore = response.has_more; 40 | startCursor = response.next_cursor; 41 | } 42 | 43 | return pages; 44 | } 45 | 46 | async function performWithAll(pages, method, args = [], limit = rateLimiter) { 47 | return await Promise.all( 48 | pages.map(async (page) => { 49 | await limit(); 50 | 51 | return await method.call(null, page, ...args); 52 | }) 53 | ); 54 | } 55 | 56 | module.exports = { 57 | fetchAllPages, 58 | fetchPages, 59 | performWithAll, 60 | rateLimiter, 61 | }; 62 | -------------------------------------------------------------------------------- /examples/shared/files.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const splitFile = require('split-file'); 4 | 5 | const { notion } = require('../shared'); 6 | const { log } = require('../shared/utils'); 7 | 8 | const NOTION_FILE_UPLOAD_URL = 'https://api.notion.com/v1/file_uploads'; 9 | const PART_SIZE = 10 * 1024 * 1024; // 10MB chunks for multi-part upload 10 | const SINGLE_PART_LIMIT = 20 * 1024 * 1024; // 20MB limit for single-part upload 11 | const MAX_PARTS = 1000; 12 | 13 | const NOTION_HEADERS = { 14 | Authorization: `Bearer ${process.env.NOTION_API_TOKEN}`, 15 | 'Notion-Version': '2022-06-28', 16 | }; 17 | 18 | const JSON_HEADERS = { 19 | ...NOTION_HEADERS, 20 | Accept: 'application/json', 21 | 'Content-Type': 'application/json', 22 | }; 23 | 24 | async function createFileUpload(options = { mode: 'single_part' }) { 25 | try { 26 | return await notion.fileUploads.create(options); 27 | } catch (error) { 28 | console.error('Error creating file upload:', error); 29 | throw error; 30 | } 31 | } 32 | 33 | async function attachFileToProperty(upload, page, property = 'File', name = null) { 34 | const properties = { 35 | [property]: { 36 | type: 'files', 37 | files: [ 38 | { 39 | type: 'file_upload', 40 | file_upload: { 41 | id: upload.id, 42 | }, 43 | ...(name && { name }), 44 | }, 45 | ], 46 | }, 47 | }; 48 | 49 | return await notion.pages.update({ 50 | page_id: page.id, 51 | properties, 52 | }); 53 | } 54 | 55 | async function attachFileToPage(upload, page, name = null, afterBlockId = null) { 56 | const contentType = getContentType(upload.filename); 57 | const blockType = getNotionBlockType(contentType); 58 | 59 | try { 60 | const children = [ 61 | { 62 | type: blockType, 63 | [blockType]: { 64 | type: 'file_upload', 65 | file_upload: { id: upload.id }, 66 | ...(name && { name }), 67 | }, 68 | }, 69 | ]; 70 | 71 | const response = await notion.blocks.children.append({ 72 | block_id: page.id, 73 | children, 74 | ...(afterBlockId && { after: afterBlockId }), 75 | }); 76 | 77 | return response; 78 | } catch (error) { 79 | console.error('Error adding file block:', error); 80 | throw error; 81 | } 82 | } 83 | 84 | async function attachFileAsCover(upload, page) { 85 | return await notion.pages.update({ 86 | page_id: page.id, 87 | cover: { 88 | type: 'file_upload', 89 | file_upload: { 90 | id: upload.id, 91 | }, 92 | }, 93 | }); 94 | } 95 | 96 | async function uploadFileAttachment(filePath, page, property = 'File', name = null) { 97 | const { upload } = await uploadFile(filePath); 98 | await attachFileToProperty(upload, page, property, name); 99 | return upload; 100 | } 101 | 102 | async function uploadFileBlock(filePath, page) { 103 | const { upload } = await uploadFile(filePath); 104 | await attachFileToPage(upload, page); 105 | return upload; 106 | } 107 | 108 | async function uploadCoverImage(filePath, page) { 109 | const { upload } = await uploadFile(filePath); 110 | await attachFileAsCover(upload, page); 111 | return upload; 112 | } 113 | 114 | async function getFileSize(filePath) { 115 | const stats = await fs.promises.stat(filePath); 116 | return stats.size; 117 | } 118 | 119 | async function uploadPart(file, blob, partNumber = null) { 120 | const params = { 121 | file_upload_id: file.id, 122 | file: { 123 | data: blob, 124 | filename: file.filename, 125 | }, 126 | }; 127 | 128 | if (partNumber) { 129 | console.log('uploading part', partNumber); 130 | // Minor issue with the API, part_number must be a string 131 | params.part_number = partNumber.toString(); 132 | } 133 | 134 | return await notion.fileUploads.send(params); 135 | } 136 | 137 | async function completeMultiPartUpload(file) { 138 | console.log('completing upload'); 139 | 140 | return await notion.fileUploads.complete({ 141 | file_upload_id: file.id, 142 | }); 143 | } 144 | 145 | async function uploadFile(filePath, fileName = path.basename(filePath)) { 146 | const fileSize = await getFileSize(filePath); 147 | const needsMultiPart = fileSize > SINGLE_PART_LIMIT; 148 | const contentType = getContentType(fileName); 149 | 150 | if (!contentType) { 151 | throw new Error(`Unsupported file type: ${fileName}`); 152 | } 153 | 154 | let file, 155 | upload, 156 | parts = []; 157 | 158 | try { 159 | if (needsMultiPart) { 160 | parts = await splitFile.splitFileBySize(filePath, PART_SIZE); 161 | 162 | if (parts.length > MAX_PARTS) { 163 | throw new Error(`File is too large. Maximum number of parts is ${MAX_PARTS}.`); 164 | } 165 | 166 | // Create multi-part upload 167 | file = await createFileUpload({ 168 | mode: 'multi_part', 169 | number_of_parts: parts.length, 170 | filename: fileName, 171 | content_type: contentType, 172 | }); 173 | 174 | for (let i = 1; i <= parts.length; i++) { 175 | const buffer = await fs.promises.readFile(parts[i - 1]); 176 | const blob = new Blob([buffer], { type: contentType }); 177 | upload = await uploadPart(file, blob, i); 178 | } 179 | 180 | // Complete the upload 181 | upload = await completeMultiPartUpload(file); 182 | } else { 183 | // Single-part upload 184 | const buffer = await fs.promises.readFile(filePath); 185 | const blob = new Blob([buffer], { type: contentType }); 186 | file = await createFileUpload(); 187 | upload = await uploadPart(file, blob); 188 | } 189 | 190 | return { file, upload }; 191 | } catch (error) { 192 | if (error.response) { 193 | console.error('Upload error response:', error.response.data); 194 | throw new Error(`Upload failed with status: ${error.response.status}`); 195 | } 196 | throw error; 197 | } finally { 198 | // Clean up temporary files 199 | for (const part of parts) { 200 | await fs.promises.unlink(part); 201 | } 202 | } 203 | } 204 | 205 | const mimeGroups = { 206 | audio: { 207 | '.aac': 'audio/aac', 208 | '.mid': 'audio/midi', 209 | '.midi': 'audio/midi', 210 | '.mp3': 'audio/mpeg', 211 | '.ogg': 'audio/ogg', 212 | '.wav': 'audio/wav', 213 | '.wma': 'audio/x-ms-wma', 214 | }, 215 | file: { 216 | '.json': 'application/json', 217 | '.pdf': 'application/pdf', 218 | '.txt': 'text/plain', 219 | }, 220 | image: { 221 | '.gif': 'image/gif', 222 | '.heic': 'image/heic', 223 | '.ico': 'image/vnd.microsoft.icon', 224 | '.jpeg': 'image/jpeg', 225 | '.jpg': 'image/jpeg', 226 | '.png': 'image/png', 227 | '.svg': 'image/svg+xml', 228 | '.tif': 'image/tiff', 229 | '.tiff': 'image/tiff', 230 | '.webp': 'image/webp', 231 | }, 232 | video: { 233 | '.amv': 'video/x-amv', 234 | '.asf': 'video/x-ms-asf', 235 | '.avi': 'video/x-msvideo', 236 | '.f4v': 'video/x-f4v', 237 | '.flv': 'video/x-flv', 238 | '.gifv': 'video/mp4', 239 | '.m4v': 'video/mp4', 240 | '.mkv': 'video/webm', 241 | '.mov': 'video/quicktime', 242 | '.mp4': 'video/mp4', 243 | '.mpeg': 'video/mpeg', 244 | '.qt': 'video/quicktime', 245 | '.wmv': 'video/x-ms-wmv', 246 | }, 247 | }; 248 | 249 | const extToMime = {}; 250 | const mimeToGroup = {}; 251 | 252 | for (const [group, extMap] of Object.entries(mimeGroups)) { 253 | for (const [ext, mime] of Object.entries(extMap)) { 254 | extToMime[ext] = mime; 255 | mimeToGroup[mime] = group; 256 | } 257 | } 258 | 259 | function getContentType(filename) { 260 | const ext = path.extname(filename).toLowerCase(); 261 | return extToMime[ext]; 262 | } 263 | 264 | function getNotionBlockType(contentType) { 265 | return mimeToGroup[contentType] || 'file'; 266 | } 267 | 268 | function getNotionBlockTypeFromFilename(filename) { 269 | return getNotionBlockType(getContentType(filename)); 270 | } 271 | 272 | module.exports = { 273 | attachFileAsCover, 274 | attachFileToPage, 275 | attachFileToProperty, 276 | createFileUpload, 277 | getContentType, 278 | getNotionBlockType, 279 | getNotionBlockTypeFromFilename, 280 | uploadCoverImage, 281 | uploadFile, 282 | uploadFileAttachment, 283 | uploadFileBlock, 284 | }; 285 | -------------------------------------------------------------------------------- /examples/shared/index.js: -------------------------------------------------------------------------------- 1 | const dotenv = require('dotenv'); 2 | const { Client } = require('@notionhq/client'); 3 | const yargs = require('yargs/yargs')(process.argv.slice(2)); 4 | 5 | dotenv.config(); 6 | 7 | const auth = yargs.argv.notionApiToken || process.env.NOTION_API_TOKEN; 8 | const notion = new Client({ auth }); 9 | 10 | module.exports = { 11 | notion, 12 | yargs, 13 | }; 14 | -------------------------------------------------------------------------------- /examples/shared/props.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | function date(start = new Date(), end = null, time_zone = null) { 4 | const date = { 5 | start, 6 | time_zone, 7 | }; 8 | 9 | if (end) { 10 | date.end = date; 11 | } 12 | 13 | return { date }; 14 | } 15 | 16 | function emoji(name) { 17 | return { 18 | type: 'emoji', 19 | emoji: name, 20 | }; 21 | } 22 | 23 | function icon(name, color = 'gray') { 24 | return { 25 | type: 'external', 26 | external: { url: `https://www.notion.so/icons/${name}_${color}.svg` }, 27 | }; 28 | } 29 | 30 | function number(number) { 31 | return { 32 | number: Number(number), 33 | }; 34 | } 35 | 36 | function pageTitle(content) { 37 | return { 38 | title: [text(content)], 39 | }; 40 | } 41 | 42 | function richText(content, link = null) { 43 | return { 44 | rich_text: [text(content, link)], 45 | }; 46 | } 47 | 48 | function select(name) { 49 | return { 50 | select: { 51 | name, 52 | }, 53 | }; 54 | } 55 | 56 | function timestamps() { 57 | return { 58 | 'Created At': { created_time: {} }, 59 | 'Updated At': { last_edited_time: {} }, 60 | }; 61 | } 62 | 63 | function text(content, link = null) { 64 | return { 65 | type: 'text', 66 | text: { 67 | content, 68 | link, 69 | }, 70 | }; 71 | } 72 | 73 | function url(url) { 74 | return { 75 | url, 76 | }; 77 | } 78 | 79 | /** 80 | * From an existing database, create a schema that can be 81 | * used to create a new database from the source database. 82 | */ 83 | function getPropertySchema(properties, omitKeys = ['id']) { 84 | // Cannot create Status with API yet :( 85 | // Other API limitations include the fact that the database creation 86 | // end point does not observe the order you supply the properties. They 87 | // will always be alphabetical after creation. Yet returning the properties 88 | // in the API will give you neither the order present in the app NOR 89 | // alphabetical, so that's fun. 90 | // 91 | // FIXME: remove status omission when API supports 92 | return _.pickBy(omitProps(properties, omitKeys), (prop) => prop.type !== 'status'); 93 | } 94 | 95 | /** 96 | * Recursively remove properties by key. 97 | */ 98 | function omitProps(properties, keys = 'id') { 99 | keys = _.keyBy(Array.isArray(keys) ? keys : [keys]); 100 | 101 | function omit(props) { 102 | return _.transform(props, function (result, value, key) { 103 | if (key in keys) { 104 | return; 105 | } 106 | 107 | result[key] = _.isObject(value) ? omit(value) : value; 108 | }); 109 | } 110 | 111 | return omit(properties); 112 | } 113 | 114 | module.exports = { 115 | date, 116 | emoji, 117 | getPropertySchema, 118 | icon, 119 | number, 120 | pageTitle, 121 | richText, 122 | select, 123 | timestamps, 124 | text, 125 | url, 126 | }; 127 | -------------------------------------------------------------------------------- /examples/shared/scim.js: -------------------------------------------------------------------------------- 1 | const dotenv = require('dotenv'); 2 | const { default: axios } = require('axios'); 3 | const yargs = require('yargs/yargs')(process.argv.slice(2)); 4 | 5 | const SCIM_BASE_URL = 'https://api.notion.com/scim/v2/'; 6 | const SCIM_SCHEMA_BASE = 'urn:ietf:params:scim:schemas:core:2.0:'; 7 | const SCIM_SCHEMA_GROUP = `${SCIM_SCHEMA_BASE}Group`; 8 | const SCIM_SCHEMA_USER = `${SCIM_SCHEMA_BASE}User`; 9 | const SCIM_SCHEMA_PATCH_OP = 'urn:ietf:params:scim:api:messages:2.0:PatchOp'; 10 | 11 | const RED = '\x1b[31m'; 12 | const RESET = '\x1b[0m'; 13 | const RED_COLOR = RED + '%s' + RESET; 14 | 15 | dotenv.config(); 16 | 17 | const auth = yargs.argv.notionScimToken || process.env.NOTION_SCIM_TOKEN; 18 | 19 | const scim = axios.create({ 20 | baseURL: SCIM_BASE_URL, 21 | headers: { 22 | Authorization: `Bearer ${auth}`, 23 | }, 24 | }); 25 | 26 | module.exports = { 27 | RED, 28 | RESET, 29 | RED_COLOR, 30 | SCIM_SCHEMA_GROUP, 31 | SCIM_SCHEMA_PATCH_OP, 32 | SCIM_SCHEMA_USER, 33 | scim, 34 | yargs, 35 | }; 36 | -------------------------------------------------------------------------------- /examples/shared/titled-date.js: -------------------------------------------------------------------------------- 1 | const props = require('../shared/props'); 2 | 3 | function pad(n) { 4 | return n < 10 ? `0${n}` : n; 5 | } 6 | 7 | /** 8 | * Create a formatted title and date creation object for a Notion page. 9 | * 10 | * titledDate('Journal') 11 | * 12 | * @param {string} titlePrefix 13 | * @param {Date} date 14 | * @param {string} titleProp 15 | * @param {string} dateProp 16 | * @returns 17 | */ 18 | function titledDate(titlePrefix, date = new Date(), titleProp = 'Name', dateProp = 'Date') { 19 | const parts = date.toDateString().split(' ').slice(1); 20 | const dateTitle = `${parts[0]} ${parts[1]}, ${parts[2]}`; 21 | 22 | if (typeof date !== 'string') { 23 | date = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; 24 | } 25 | 26 | return { 27 | [titleProp]: props.pageTitle(`${titlePrefix}: ${dateTitle}`), 28 | [dateProp]: props.date(date), 29 | }; 30 | } 31 | 32 | module.exports = titledDate; 33 | -------------------------------------------------------------------------------- /examples/shared/utils.js: -------------------------------------------------------------------------------- 1 | function log(object) { 2 | console.log(JSON.stringify(object, undefined, 2)); 3 | } 4 | 5 | module.exports = { log }; 6 | -------------------------------------------------------------------------------- /examples/wordle/create-game/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a Wordle game instance. 3 | * 4 | * @see https://mariepoulin.gumroad.com/l/wordle-in-notion 5 | * 6 | * Arguments: 7 | * 8 | * --words-db-id: ID of the Wordle Words database 9 | * --games-db-id: ID of the Wordle Games database 10 | */ 11 | 12 | const { formatInTimeZone } = require('date-fns-tz'); 13 | const { notion, yargs } = require('../../shared'); 14 | const words = require('./words'); 15 | 16 | // The "Wordle Words" database ID 17 | const wordsDbId = '6e9aa51b6e284a7280506b595f91bd9c'; 18 | // The "Wordle Games" database ID 19 | const gamesDbId = '9e0d31a2f1bb41a1992b4e6f7f122a5f'; 20 | 21 | // Defaults can be supplied via the command line 22 | const argv = yargs.default({ 23 | gamesDbId, 24 | wordsDbId, 25 | }).argv; 26 | 27 | // Pick a word, any word... 28 | const randomWord = words[Math.floor(Math.random() * words.length)]; 29 | 30 | (async () => { 31 | const { 32 | results: [word], 33 | } = await notion.databases.query({ 34 | database_id: argv.wordsDbId, 35 | filter: { 36 | property: 'Word', 37 | title: { 38 | equals: randomWord, 39 | }, 40 | }, 41 | }); 42 | 43 | if (!word) { 44 | throw new Error(`"${randomWord}" could not be found in the Wordle Words database`); 45 | } 46 | 47 | // Use date-fns to format our dates. Intl API is a nightmare. 48 | const date = new Date(); 49 | const timeZone = 'America/Los_Angeles'; 50 | const dateTitle = formatInTimeZone(date, timeZone, 'MMM do, yyyy'); 51 | const dateValue = formatInTimeZone(date, timeZone, 'yyyy-MM-dd'); 52 | 53 | const params = { 54 | parent: { 55 | database_id: argv.gamesDbId, 56 | }, 57 | icon: { 58 | type: 'emoji', 59 | emoji: '🟩', 60 | }, 61 | properties: { 62 | Name: { 63 | title: [ 64 | { 65 | type: 'text', 66 | text: { 67 | content: `Wordle: ${dateTitle}`, 68 | }, 69 | }, 70 | ], 71 | }, 72 | Date: { 73 | date: { 74 | start: dateValue, 75 | time_zone: null, 76 | }, 77 | }, 78 | "Today's Word": { 79 | relation: [ 80 | { 81 | id: word.id, 82 | }, 83 | ], 84 | }, 85 | }, 86 | }; 87 | 88 | const page = await notion.pages.create(params); 89 | 90 | console.log(`Created game board at ${page.url}`); 91 | })(); 92 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notion-api-explorer", 3 | "version": "1.0.0", 4 | "description": "Notion API explorer and examples", 5 | "main": "index.js", 6 | "author": "Benjamin Borowski ", 7 | "license": "MIT", 8 | "dependencies": { 9 | "@notionhq/client": "^3.1.1", 10 | "@notionhq/notion-mcp-server": "^1.8.0", 11 | "async-sema": "^3.1.1", 12 | "axios": "^1.4.0", 13 | "date-fns": "^2.28.0", 14 | "date-fns-tz": "^1.3.5", 15 | "dotenv": "^16.3.1", 16 | "lodash": "^4.17.21", 17 | "split-file": "^2.3.0", 18 | "yargs": "^17.5.1" 19 | }, 20 | "devDependencies": { 21 | "prettier": "2.7.1" 22 | } 23 | } 24 | --------------------------------------------------------------------------------