-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Export blocks, switch to ESM, add types
- Loading branch information
Showing
12 changed files
with
2,653 additions
and
260 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
.eslintrc.js | ||
*.config.js | ||
build | ||
|
||
# Next.js build folder | ||
.next |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
module.exports = { | ||
extends: ['@upleveled/upleveled'], | ||
}; |
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
name: 'Export Notion Blocks and Commit to Git' | ||
|
||
on: | ||
push: | ||
branches: | ||
- main | ||
schedule: | ||
- cron: '0 0 * * *' | ||
|
||
jobs: | ||
backup: | ||
runs-on: ubuntu-latest | ||
name: Backup | ||
timeout-minutes: 15 | ||
steps: | ||
- uses: actions/checkout@v2 | ||
- uses: actions/setup-node@v2 | ||
with: | ||
node-version: '15' | ||
|
||
- name: Install dependencies | ||
run: yarn --frozen-lockfile | ||
|
||
- name: Run backup script | ||
run: node index.js | ||
env: | ||
NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} | ||
|
||
- name: Commit to Git | ||
run: | | ||
git config --local user.email "$(git log --format='%ae' HEAD^!)" | ||
git config --local user.name "$(git log --format='%an' HEAD^!)" | ||
git remote add github "https://$GITHUB_ACTOR:[email protected]/$GITHUB_REPOSITORY.git" | ||
git pull github ${GITHUB_REF} --ff-only | ||
git add . | ||
if [ -z "$(git status --porcelain)" ]; then | ||
exit 0 | ||
fi | ||
git commit -m "Update Notion export" | ||
git push github HEAD:${GITHUB_REF} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,2 @@ | ||
node_modules/ | ||
workspace/ | ||
workspace.zip | ||
node_modules | ||
.eslintcache |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,25 +1,25 @@ | ||
# Notion Guardian | ||
# `notion-backup` | ||
|
||
A tool that automatically backups your [Notion](notion.so) workspace and commits changes to another repository. | ||
> Export [Notion](https://www.notion.so/) pages and subpages to a GitHub repo on a schedule (eg. to be used as a scheduled backup) | ||
Notion Guardian offers a quick way to setup a secure backup of your data in a private repository — allowing you to track how your notes change over time and to know that your data is safe. | ||
## Setup | ||
|
||
The tool separates the logic for running the export and the actual workspace data into two repositories. This way your backups are not cluttered with other scripts. | ||
1. Fork this repository and make your fork private | ||
2. Edit `index.js` to add the Notion pages you want to export to the `blocks` array at the top of the file | ||
3. Optional: Edit `index.js` to specify a different export format, time zone or locale | ||
4. Create a new repo secret called `NOTION_TOKEN` with the instructions in [this article](https://artur-en.medium.com/automated-notion-backups-f6af4edc298d) | ||
5. Click on the Actions tab at the top and enable actions | ||
6. On the left sidebar click the "Export Notion Blocks and Commit to Git" workflow and enable Scheduled Actions (there should be a notification that they are disabled) | ||
7. After the action has run, check the `exports` folder to verify that the action is running correctly | ||
|
||
## How to setup | ||
## How | ||
|
||
1. Create a separate private repository for your backups to live in (e.g. "my-notion-backup"). Make sure you create a `main` branch — for example by clicking "Add a README file" when creating the repo. | ||
2. Fork this repository ("notion-guardian"). | ||
3. Create a Personal Access Token ([docs](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/creating-a-personal-access-token)) with the "repo" scope and store it as `REPO_PERSONAL_ACCESS_TOKEN` in the secrets of the forked repo. | ||
4. Store your GitHub username in the `REPO_USERNAME` secret. | ||
5. Store the name of your newly created private repo in the `REPO_NAME` secret (in this case "my-notion-backup"). | ||
6. Store the email that should be used to commit changes (usually your GitHub account email) in the `REPO_EMAIL` secret. | ||
7. Obtain your Notion space-id and token as described [in this Medium post](https://medium.com/@arturburtsev/automated-notion-backups-f6af4edc298d). Store it in the `NOTION_SPACE_ID` and `NOTION_TOKEN` secret. | ||
8. Click the "Actions" tab on the forked repo and enable actions by clicking the button. | ||
9. On the left sidebar click the "Backup Notion Workspace" workflow. A notice will tell you that "Scheduled Actions" are disabled, so go ahead and click the button to enable them. | ||
10. Wait until the action runs for the first time or push a commit to the repo to trigger the first backup. | ||
11. Check your private repo to see that an automatic commit with your Notion workspace data has been made. Done 🙌 | ||
The GitHub Actions workflow is scheduled to run once a day to: | ||
|
||
## How it works | ||
1. export each specified Notion block | ||
2. wait until each export is done | ||
3. download, unzip and commit the content from each export to the repository | ||
|
||
This repo contains a GitHub workflow that runs every hour and for every push to this repo. The workflow will execute the script which makes an export request to Notion, waits for it to finish and downloads the workspace content to a temporary directory. The workflow will then commit this directory to the repository configured in the repo secrets. | ||
## Credit | ||
|
||
This script is heavily based on [`notion-guardian`](https://github.com/richartkeil/notion-guardian) by [@richartkeil](https://github.com/richartkeil). |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,94 +1,187 @@ | ||
const axios = require(`axios`); | ||
const extract = require(`extract-zip`); | ||
const { createWriteStream, mkdirSync, rmdirSync, unlinkSync } = require(`fs`); | ||
const { join } = require(`path`); | ||
|
||
const notionAPI = `https://www.notion.so/api/v3`; | ||
const { NOTION_TOKEN, NOTION_SPACE_ID } = process.env; | ||
const client = axios.create({ | ||
baseURL: notionAPI, | ||
headers: { | ||
Cookie: `token_v2=${NOTION_TOKEN}`, | ||
import axios from 'axios'; | ||
import extract from 'extract-zip'; | ||
import { createWriteStream, mkdirSync, rmSync, unlinkSync } from 'fs'; | ||
import pMap from 'p-map'; | ||
import path from 'path'; | ||
|
||
const blocks = [ | ||
{ | ||
// Find the page block ID by either: | ||
// 1. Copying the alphanumeric part at the end of the Notion page URL | ||
// and separate it with dashes in the same format as below | ||
// (number of characters between dashes: 8-4-4-4-12) | ||
// 2. Inspecting network requests in the DevTools | ||
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', | ||
// Choose a directory name for your export to appear in the `exports` folder | ||
dirName: 'notion-page-a', | ||
// Should all of the subpages also be exported? | ||
recursive: false, | ||
}, | ||
}); | ||
]; | ||
|
||
if (!NOTION_TOKEN || !NOTION_SPACE_ID) { | ||
if (!process.env.NOTION_TOKEN) { | ||
console.error( | ||
`Environment variable NOTION_TOKEN or NOTION_SPACE_ID is missing. Check the README.md for more information.` | ||
'Environment variable NOTION_TOKEN is missing. Check the README.md for more information.', | ||
); | ||
process.exit(1); | ||
} | ||
|
||
const sleep = async (seconds) => { | ||
/** | ||
* @typedef {{ | ||
* id: string; | ||
* state: string | null; | ||
* status: { | ||
* pagesExported: number | null; | ||
* exportURL: string | null; | ||
* }; | ||
* }} BlockTask | ||
*/ | ||
|
||
/** | ||
* @typedef {{ | ||
* id: string; | ||
* state: string | null; | ||
* status?: { | ||
* pagesExported: number | null; | ||
* exportURL: string | null; | ||
* }; | ||
* }} Task | ||
*/ | ||
|
||
const client = axios.create({ | ||
// Notion unofficial API | ||
baseURL: 'https://www.notion.so/api/v3', | ||
headers: { | ||
Cookie: `token_v2=${process.env.NOTION_TOKEN}`, | ||
}, | ||
}); | ||
|
||
async function delay(/** @type {number} */ ms) { | ||
console.log(`Waiting ${ms / 1000} seconds before polling again...`); | ||
return new Promise((resolve) => { | ||
setTimeout(resolve, seconds * 1000); | ||
setTimeout(resolve, ms); | ||
}); | ||
} | ||
|
||
// Enqueue all export tasks immediately, without | ||
// waiting for the export tasks to complete | ||
const enqueuedBlocks = await pMap(blocks, async (block) => { | ||
const { | ||
data: { taskId }, | ||
} = await client.post('enqueueTask', { | ||
task: { | ||
eventName: 'exportBlock', | ||
request: { | ||
blockId: block.id, | ||
exportOptions: { | ||
exportType: 'markdown', | ||
locale: 'en', | ||
timeZone: 'Europe/Vienna', | ||
}, | ||
recursive: block.recursive, | ||
}, | ||
}, | ||
}); | ||
}; | ||
|
||
const round = (number) => Math.round(number * 100) / 100; | ||
console.log(`Started export of block ${block.dirName} as task ${taskId}`); | ||
|
||
const exportFromNotion = async (destination, format) => { | ||
/** @type {BlockTask} */ | ||
const task = { | ||
eventName: `exportSpace`, | ||
request: { | ||
spaceId: NOTION_SPACE_ID, | ||
exportOptions: { | ||
exportType: format, | ||
timeZone: `Europe/Berlin`, | ||
locale: `en`, | ||
}, | ||
id: taskId, | ||
state: null, | ||
status: { | ||
pagesExported: null, | ||
exportURL: null, | ||
}, | ||
}; | ||
|
||
return { | ||
...block, | ||
task: task, | ||
}; | ||
}); | ||
|
||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition | ||
while (true) { | ||
const incompleteEnqueuedBlocks = enqueuedBlocks.filter( | ||
({ task }) => task.state !== 'success', | ||
); | ||
|
||
const taskIds = incompleteEnqueuedBlocks.map(({ task }) => task.id); | ||
|
||
const { | ||
data: { taskId }, | ||
} = await client.post(`enqueueTask`, { task }); | ||
data: { results }, | ||
} = await client.post('getTasks', { | ||
taskIds: taskIds, | ||
}); | ||
|
||
console.log(`Started Export as task [${taskId}].\n`); | ||
const blocksWithTaskProgress = results.reduce( | ||
( | ||
/** @type {typeof incompleteEnqueuedBlocks} */ blocksAcc, | ||
/** @type {Task} */ task, | ||
) => { | ||
const block = enqueuedBlocks.find(({ task: { id } }) => id === task.id); | ||
|
||
let exportURL; | ||
while (true) { | ||
await sleep(2); | ||
const { | ||
data: { results: tasks }, | ||
} = await client.post(`getTasks`, { taskIds: [taskId] }); | ||
const task = tasks.find((t) => t.id === taskId); | ||
if (!block || !task.status) return blocksAcc; | ||
|
||
console.log(`Exported ${task.status.pagesExported} pages.`); | ||
// Mutate original object in enqueuedBlocks for while loop exit condition | ||
block.task.state = task.state; | ||
block.task.status.pagesExported = task.status.pagesExported; | ||
block.task.status.exportURL = task.status.exportURL; | ||
|
||
if (task.state === `success`) { | ||
exportURL = task.status.exportURL; | ||
console.log(`\nExport finished.`); | ||
break; | ||
} | ||
} | ||
return blocksAcc.concat(block); | ||
}, | ||
/** @type {typeof incompleteEnqueuedBlocks} */ [], | ||
); | ||
|
||
const response = await client({ | ||
method: `GET`, | ||
url: exportURL, | ||
responseType: `stream`, | ||
}); | ||
for (const block of blocksWithTaskProgress) { | ||
console.log( | ||
`Exported ${block.task.status.pagesExported} pages for ${block.dirName}`, | ||
); | ||
|
||
const size = response.headers["content-length"]; | ||
console.log(`Downloading ${round(size / 1000 / 1000)}mb...`); | ||
if (block.task.state === 'success') { | ||
const backupDirPath = path.join(process.cwd(), 'exports', block.dirName); | ||
|
||
const stream = response.data.pipe(createWriteStream(destination)); | ||
await new Promise((resolve, reject) => { | ||
stream.on(`close`, resolve); | ||
stream.on(`error`, reject); | ||
}); | ||
}; | ||
const temporaryZipPath = path.join( | ||
process.cwd(), | ||
'exports', | ||
`${block.dirName}.zip`, | ||
); | ||
|
||
const run = async () => { | ||
const workspaceDir = join(process.cwd(), `workspace`); | ||
const workspaceZip = join(process.cwd(), `workspace.zip`); | ||
console.log(`Export finished for ${block.dirName}`); | ||
|
||
await exportFromNotion(workspaceZip, `markdown`); | ||
rmdirSync(workspaceDir, { recursive: true }); | ||
mkdirSync(workspaceDir, { recursive: true }); | ||
await extract(workspaceZip, { dir: workspaceDir }); | ||
unlinkSync(workspaceZip); | ||
const response = await client({ | ||
method: 'GET', | ||
url: block.task.status.exportURL, | ||
responseType: 'stream', | ||
}); | ||
|
||
console.log(`✅ Export downloaded and unzipped.`); | ||
}; | ||
const sizeInMb = response.headers['content-length'] / 1000 / 1000; | ||
console.log(`Downloading ${Math.round(sizeInMb * 1000) / 1000}mb...`); | ||
|
||
const stream = response.data.pipe(createWriteStream(temporaryZipPath)); | ||
|
||
await new Promise((resolve, reject) => { | ||
stream.on('close', resolve); | ||
stream.on('error', reject); | ||
}); | ||
|
||
rmSync(backupDirPath, { recursive: true, force: true }); | ||
mkdirSync(backupDirPath, { recursive: true }); | ||
await extract(temporaryZipPath, { dir: backupDirPath }); | ||
unlinkSync(temporaryZipPath); | ||
|
||
console.log(`✅ Export of ${block.dirName} downloaded and unzipped`); | ||
} | ||
} | ||
|
||
// If all blocks are done, break out of the loop | ||
if (!enqueuedBlocks.find(({ task }) => task.state !== 'success')) { | ||
break; | ||
} | ||
|
||
// Rate limit polling | ||
await delay(30000); | ||
} | ||
|
||
run(); | ||
console.log('✅ All exports successful'); |
Oops, something went wrong.