Skip to content

Commit

Permalink
Export blocks, switch to ESM, add types
Browse files Browse the repository at this point in the history
  • Loading branch information
karlhorky committed Apr 21, 2021
1 parent 0d80d06 commit 669c700
Show file tree
Hide file tree
Showing 12 changed files with 2,653 additions and 260 deletions.
6 changes: 6 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.eslintrc.js
*.config.js
build

# Next.js build folder
.next
3 changes: 3 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
extends: ['@upleveled/upleveled'],
};
37 changes: 0 additions & 37 deletions .github/workflows/backup.yml

This file was deleted.

40 changes: 40 additions & 0 deletions .github/workflows/export-notion-blocks-and-commit.yml
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}
5 changes: 2 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
node_modules/
workspace/
workspace.zip
node_modules
.eslintcache
36 changes: 18 additions & 18 deletions README.md
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 added exports/.gitkeep
Empty file.
229 changes: 161 additions & 68 deletions index.js
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');
Loading

0 comments on commit 669c700

Please sign in to comment.