├── .dockerignore ├── .github ├── disabled-workflows │ └── .gitkeep └── workflows │ ├── build-deploy.yml │ ├── build-run.yml │ └── docker-run.yml ├── .gitignore ├── Dockerfile ├── README.md ├── documentation └── setup.md ├── ignored └── .gitkeep ├── images └── notion-search-request.png ├── notion-api-response-structure.json ├── pom.xml ├── src ├── main │ └── java │ │ └── com │ │ └── greydev │ │ └── notionbackup │ │ ├── NotionBackup.java │ │ ├── NotionClient.java │ │ ├── cloudstorage │ │ ├── CloudStorageClient.java │ │ ├── dropbox │ │ │ ├── DropboxClient.java │ │ │ └── DropboxServiceFactory.java │ │ ├── googledrive │ │ │ ├── GoogleDriveClient.java │ │ │ └── GoogleDriveServiceFactory.java │ │ ├── nextcloud │ │ │ └── NextcloudClient.java │ │ └── pcloud │ │ │ ├── PCloudApiClientFactory.java │ │ │ └── PCloudClient.java │ │ └── model │ │ ├── Result.java │ │ ├── Results.java │ │ └── Status.java └── test │ ├── java │ └── com │ │ └── greydev │ │ └── notionbackup │ │ └── cloudstorage │ │ ├── dropbox │ │ └── DropboxClientTest.java │ │ └── googledrive │ │ └── GoogleDriveClientTest.java │ └── resources │ └── testFileToUpload.txt └── testing-with-curl └── README.md /.dockerignore: -------------------------------------------------------------------------------- 1 | .env 2 | **/.env -------------------------------------------------------------------------------- /.github/disabled-workflows/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jckleiner/notion-backup/26bc0aa2d0c21a561d30540d7f81e304495534b8/.github/disabled-workflows/.gitkeep -------------------------------------------------------------------------------- /.github/workflows/build-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build and push new docker image 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | # adds a "run workflow" button to the page 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build-deploy-jar: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up JDK 1.11 17 | uses: actions/setup-java@v1 18 | with: 19 | java-version: 1.11 20 | 21 | - name: Build with Maven 22 | run: mvn -B package --file pom.xml --no-transfer-progress 23 | 24 | # needed for multi-platform builds 25 | - name: Set up QEMU 26 | uses: docker/setup-qemu-action@v2 27 | 28 | # needed for multi-platform builds 29 | - name: Set up Docker Buildx 30 | uses: docker/setup-buildx-action@v2 31 | 32 | - name: Login to GitHub Container Registry 33 | uses: docker/login-action@v1 34 | with: 35 | registry: ghcr.io 36 | username: ${{ github.actor }} 37 | password: ${{ secrets.GITHUB_TOKEN }} # provided by GitHub 38 | 39 | - name: Build and push image 40 | uses: docker/build-push-action@v3 41 | with: 42 | # needed for multi-platform builds 43 | platforms: linux/amd64,linux/arm64 44 | context: . 45 | push: true 46 | tags: ghcr.io/jckleiner/notion-backup:latest 47 | build-args: | 48 | PATH_TO_JAR=./target/notion-backup-1.0-SNAPSHOT.jar 49 | 50 | -------------------------------------------------------------------------------- /.github/workflows/build-run.yml: -------------------------------------------------------------------------------- 1 | name: Start Notion backup 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | # adds a "run workflow" button to the page 8 | workflow_dispatch: 9 | 10 | # will be triggered at 04:05 on every 2nd day-of-month from 2 through 30. 11 | schedule: 12 | - cron: '5 4 2-30/2 * *' 13 | 14 | jobs: 15 | build-and-run: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v2 19 | 20 | - name: Set up JDK 1.11 21 | uses: actions/setup-java@v1 22 | with: 23 | java-version: 1.11 24 | 25 | - name: Build with Maven 26 | run: mvn -B package --file pom.xml --no-transfer-progress 27 | 28 | - name: Start Notion Backup 29 | run: java -jar ./target/notion-backup-1.0-SNAPSHOT.jar 30 | env: 31 | NOTION_SPACE_ID: ${{ secrets.NOTION_SPACE_ID }} 32 | NOTION_TOKEN_V2: ${{ secrets.NOTION_TOKEN_V2 }} 33 | 34 | GOOGLE_DRIVE_ROOT_FOLDER_ID: ${{ secrets.GOOGLE_DRIVE_ROOT_FOLDER_ID }} 35 | GOOGLE_DRIVE_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_DRIVE_SERVICE_ACCOUNT }} 36 | GOOGLE_DRIVE_SERVICE_ACCOUNT_SECRET_JSON: ${{ secrets.GOOGLE_DRIVE_SERVICE_ACCOUNT_SECRET_JSON }} 37 | GOOGLE_DRIVE_SERVICE_ACCOUNT_SECRET_FILE_PATH: ${{ secrets.GOOGLE_DRIVE_SERVICE_ACCOUNT_SECRET_FILE_PATH }} 38 | 39 | DROPBOX_ACCESS_TOKEN: ${{ secrets.DROPBOX_ACCESS_TOKEN }} 40 | DROPBOX_APP_KEY: ${{ secrets.DROPBOX_APP_KEY }} 41 | DROPBOX_APP_SECRET: ${{ secrets.DROPBOX_APP_SECRET }} 42 | DROPBOX_REFRESH_TOKEN: ${{ secrets.DROPBOX_REFRESH_TOKEN }} 43 | 44 | NEXTCLOUD_EMAIL: ${{ secrets.NEXTCLOUD_EMAIL }} 45 | NEXTCLOUD_PASSWORD: ${{ secrets.NEXTCLOUD_PASSWORD }} 46 | NEXTCLOUD_WEBDAV_URL: ${{ secrets.NEXTCLOUD_WEBDAV_URL }} 47 | 48 | PCLOUD_ACCESS_TOKEN: ${{ secrets.PCLOUD_ACCESS_TOKEN }} 49 | PCLOUD_API_HOST: ${{ secrets.PCLOUD_API_HOST }} 50 | PCLOUD_FOLDER_ID: ${{ secrets.PCLOUD_FOLDER_ID }} 51 | 52 | DOWNLOADS_DIRECTORY_PATH: /tmp/ 53 | -------------------------------------------------------------------------------- /.github/workflows/docker-run.yml: -------------------------------------------------------------------------------- 1 | name: Start Notion backup (using docker) 2 | 3 | on: 4 | # adds a "run workflow" button to the page 5 | workflow_dispatch: 6 | 7 | # See https://github.com/marketplace/actions/docker-run-step 8 | # See https://github.com/addnab/docker-run-action 9 | 10 | jobs: 11 | run-docker-image: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: kohlerdominik/docker-run-action@v1 15 | with: 16 | # public, private or local image 17 | image: ghcr.io/jckleiner/notion-backup 18 | # pass or create environment variables 19 | environment: | 20 | NOTION_SPACE_ID=${{ secrets.NOTION_SPACE_ID }} 21 | NOTION_TOKEN_V2=${{ secrets.NOTION_TOKEN_V2 }} 22 | 23 | GOOGLE_DRIVE_ROOT_FOLDER_ID=${{ secrets.GOOGLE_DRIVE_ROOT_FOLDER_ID }} 24 | GOOGLE_DRIVE_SERVICE_ACCOUNT=${{ secrets.GOOGLE_DRIVE_SERVICE_ACCOUNT }} 25 | GOOGLE_DRIVE_SERVICE_ACCOUNT_SECRET_JSON=${{ secrets.GOOGLE_DRIVE_SERVICE_ACCOUNT_SECRET_JSON }} 26 | GOOGLE_DRIVE_SERVICE_ACCOUNT_SECRET_FILE_PATH=${{ secrets.GOOGLE_DRIVE_SERVICE_ACCOUNT_SECRET_FILE_PATH }} 27 | 28 | DROPBOX_ACCESS_TOKEN=${{ secrets.DROPBOX_ACCESS_TOKEN }} 29 | DROPBOX_APP_KEY=${{ secrets.DROPBOX_APP_KEY }} 30 | DROPBOX_APP_SECRET=${{ secrets.DROPBOX_APP_SECRET }} 31 | DROPBOX_REFRESH_TOKEN=${{ secrets.DROPBOX_REFRESH_TOKEN }} 32 | 33 | NEXTCLOUD_EMAIL=${{ secrets.NEXTCLOUD_EMAIL }} 34 | NEXTCLOUD_PASSWORD=${{ secrets.NEXTCLOUD_PASSWORD }} 35 | NEXTCLOUD_WEBDAV_URL=${{ secrets.NEXTCLOUD_WEBDAV_URL }} 36 | 37 | PCLOUD_ACCESS_TOKEN=${{ secrets.PCLOUD_ACCESS_TOKEN }} 38 | PCLOUD_API_HOST=${{ secrets.PCLOUD_API_HOST }} 39 | PCLOUD_FOLDER_ID=${{ secrets.PCLOUD_FOLDER_ID }} 40 | 41 | # run is required: "Error: Input required and not supplied: run" 42 | run: | 43 | uname -a 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/java,linux,maven,eclipse,intellij 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=java,linux,maven,eclipse,intellij 4 | 5 | ### Eclipse ### 6 | .metadata 7 | bin/ 8 | tmp/ 9 | *.tmp 10 | *.bak 11 | *.swp 12 | *~.nib 13 | local.properties 14 | .settings/ 15 | .loadpath 16 | .recommenders 17 | 18 | # External tool builders 19 | .externalToolBuilders/ 20 | 21 | # Locally stored "Eclipse launch configurations" 22 | *.launch 23 | 24 | # PyDev specific (Python IDE for Eclipse) 25 | *.pydevproject 26 | 27 | # CDT-specific (C/C++ Development Tooling) 28 | .cproject 29 | 30 | # CDT- autotools 31 | .autotools 32 | 33 | # Java annotation processor (APT) 34 | .factorypath 35 | 36 | # PDT-specific (PHP Development Tools) 37 | .buildpath 38 | 39 | # sbteclipse plugin 40 | .target 41 | 42 | # Tern plugin 43 | .tern-project 44 | 45 | # TeXlipse plugin 46 | .texlipse 47 | 48 | # STS (Spring Tool Suite) 49 | .springBeans 50 | 51 | # Code Recommenders 52 | .recommenders/ 53 | 54 | # Annotation Processing 55 | .apt_generated/ 56 | .apt_generated_test/ 57 | 58 | # Scala IDE specific (Scala & Java development for Eclipse) 59 | .cache-main 60 | .scala_dependencies 61 | .worksheet 62 | 63 | # Uncomment this line if you wish to ignore the project description file. 64 | # Typically, this file would be tracked if it contains build/dependency configurations: 65 | #.project 66 | 67 | ### Eclipse Patch ### 68 | # Spring Boot Tooling 69 | .sts4-cache/ 70 | 71 | ### Intellij ### 72 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 73 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 74 | 75 | # User-specific stuff 76 | .idea/**/workspace.xml 77 | .idea/**/tasks.xml 78 | .idea/**/usage.statistics.xml 79 | .idea/**/dictionaries 80 | .idea/**/shelf 81 | 82 | # Generated files 83 | .idea/**/contentModel.xml 84 | 85 | # Sensitive or high-churn files 86 | .idea/**/dataSources/ 87 | .idea/**/dataSources.ids 88 | .idea/**/dataSources.local.xml 89 | .idea/**/sqlDataSources.xml 90 | .idea/**/dynamic.xml 91 | .idea/**/uiDesigner.xml 92 | .idea/**/dbnavigator.xml 93 | 94 | # Gradle 95 | .idea/**/gradle.xml 96 | .idea/**/libraries 97 | 98 | # Gradle and Maven with auto-import 99 | # When using Gradle or Maven with auto-import, you should exclude module files, 100 | # since they will be recreated, and may cause churn. Uncomment if using 101 | # auto-import. 102 | # .idea/artifacts 103 | # .idea/compiler.xml 104 | # .idea/jarRepositories.xml 105 | # .idea/modules.xml 106 | # .idea/*.iml 107 | # .idea/modules 108 | # *.iml 109 | # *.ipr 110 | 111 | # CMake 112 | cmake-build-*/ 113 | 114 | # Mongo Explorer plugin 115 | .idea/**/mongoSettings.xml 116 | 117 | # File-based project format 118 | *.iws 119 | 120 | # IntelliJ 121 | out/ 122 | 123 | # mpeltonen/sbt-idea plugin 124 | .idea_modules/ 125 | 126 | # JIRA plugin 127 | atlassian-ide-plugin.xml 128 | 129 | # Cursive Clojure plugin 130 | .idea/replstate.xml 131 | 132 | # Crashlytics plugin (for Android Studio and IntelliJ) 133 | com_crashlytics_export_strings.xml 134 | crashlytics.properties 135 | crashlytics-build.properties 136 | fabric.properties 137 | 138 | # Editor-based Rest Client 139 | .idea/httpRequests 140 | 141 | # Android studio 3.1+ serialized cache file 142 | .idea/caches/build_file_checksums.ser 143 | 144 | ### Intellij Patch ### 145 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 146 | 147 | # *.iml 148 | # modules.xml 149 | # .idea/misc.xml 150 | # *.ipr 151 | 152 | # Sonarlint plugin 153 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 154 | .idea/**/sonarlint/ 155 | 156 | # SonarQube Plugin 157 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 158 | .idea/**/sonarIssues.xml 159 | 160 | # Markdown Navigator plugin 161 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 162 | .idea/**/markdown-navigator.xml 163 | .idea/**/markdown-navigator-enh.xml 164 | .idea/**/markdown-navigator/ 165 | 166 | # Cache file creation bug 167 | # See https://youtrack.jetbrains.com/issue/JBR-2257 168 | .idea/$CACHE_FILE$ 169 | 170 | # CodeStream plugin 171 | # https://plugins.jetbrains.com/plugin/12206-codestream 172 | .idea/codestream.xml 173 | 174 | ### Java ### 175 | # Compiled class file 176 | *.class 177 | 178 | # Log file 179 | *.log 180 | 181 | # BlueJ files 182 | *.ctxt 183 | 184 | # Mobile Tools for Java (J2ME) 185 | .mtj.tmp/ 186 | 187 | # Package Files # 188 | *.jar 189 | *.war 190 | *.nar 191 | *.ear 192 | *.zip 193 | *.tar.gz 194 | *.rar 195 | 196 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 197 | hs_err_pid* 198 | 199 | ### Linux ### 200 | *~ 201 | 202 | # temporary files which can be created if a process still has a handle open of a deleted file 203 | .fuse_hidden* 204 | 205 | # KDE directory preferences 206 | .directory 207 | 208 | # Linux trash folder which might appear on any partition or disk 209 | .Trash-* 210 | 211 | # .nfs files are created when an open file is removed but is still being accessed 212 | .nfs* 213 | 214 | ### Maven ### 215 | target/ 216 | pom.xml.tag 217 | pom.xml.releaseBackup 218 | pom.xml.versionsBackup 219 | pom.xml.next 220 | release.properties 221 | dependency-reduced-pom.xml 222 | buildNumber.properties 223 | .mvn/timing.properties 224 | # https://github.com/takari/maven-wrapper#usage-without-binary-jar 225 | .mvn/wrapper/maven-wrapper.jar 226 | 227 | # End of https://www.toptal.com/developers/gitignore/api/java,linux,maven,eclipse,intellij 228 | 229 | 230 | ### IntelliJ IDEA ### 231 | .idea 232 | *.iws 233 | *.iml 234 | *.ipr 235 | 236 | ### NetBeans ### 237 | /nbproject/private/ 238 | /nbbuild/ 239 | /dist/ 240 | /nbdist/ 241 | /.nb-gradle/ 242 | build/ 243 | 244 | ### VS Code ### 245 | .vscode/ 246 | 247 | ### Vagrant ### 248 | # General 249 | **/.vagrant/* 250 | 251 | # Log files (if you are creating logs in debug mode, uncomment this) 252 | # *.log 253 | 254 | ### Vagrant Patch ### 255 | *.box 256 | 257 | ### Sublime Text project/workspace files ### 258 | *.sublime-project 259 | *.sublime-workspace 260 | 261 | ### Notion-backup Project 262 | .env 263 | notion-export.zip 264 | credentials.json -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM openjdk:11 2 | ARG PATH_TO_JAR 3 | 4 | # Automatically links the repository with the container image deployed on GitHub Container Registry 5 | LABEL org.opencontainers.image.source="https://github.com/jckleiner/notion-backup" 6 | 7 | WORKDIR / 8 | 9 | RUN mkdir /downloads 10 | RUN chmod 755 /downloads 11 | 12 | ADD ${PATH_TO_JAR} /notion-backup.jar 13 | 14 | ENTRYPOINT ["java", "-jar", "notion-backup.jar"] 15 | 16 | 17 | ### Build/Run 18 | 19 | # Build for a specific platform: 20 | # mvn clean install && docker build --platform linux/amd64 --build-arg PATH_TO_JAR=./target/notion-backup-1.0-SNAPSHOT.jar -t jckleiner/notion-backup . 21 | # mvn clean install && docker build --platform linux/x86_64 --build-arg PATH_TO_JAR=./target/notion-backup-1.0-SNAPSHOT.jar -t jckleiner/notion-backup . 22 | 23 | # Push to DockerHub: 24 | # docker login 25 | # docker push jckleiner/notion-backup 26 | 27 | # Run Locally: 28 | # docker run --rm=true --env-file=.env jckleiner/notion-backup 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # notion-backup 2 | 3 | ![example workflow name](https://github.com/jckleiner/notion-backup/actions/workflows/build-run.yml/badge.svg?branch=master) 4 | 5 | > ⚠️ Notion changed their API around 12.2022 which broke the automatic login requests made by this tool to extract the 6 | > `token_v2`. 7 | > 8 | > To solve this new limitation, you need to copy the value of the `token_v2` cookie manually (see [How do I find 9 | > all these values?](./documentation/setup.md) for more info). 10 | 11 | 12 | Automatically backup your Notion workspace to Google Drive, Dropbox, pCloud, Nextcloud or to your local machine. 13 | 14 | ### Set Credentials 15 | 16 | Create a `.env` file with the following properties ([How do I find all these values?](./documentation/setup.md)): 17 | 18 | # Make sure not to use any quotes around these environment variables 19 | 20 | # Notion (Required) 21 | NOTION_SPACE_ID= 22 | NOTION_TOKEN_V2= 23 | # Options: markdown, html (default is markdown) 24 | NOTION_EXPORT_TYPE=markdown 25 | # Create folders for nested pages? Options: true, false (default is false) 26 | NOTION_FLATTEN_EXPORT_FILETREE=false 27 | # Should export comments? Options: true, false (default is true) 28 | NOTION_EXPORT_COMMENTS=true 29 | 30 | # Google Drive (Optional) 31 | GOOGLE_DRIVE_ROOT_FOLDER_ID= 32 | GOOGLE_DRIVE_SERVICE_ACCOUNT= 33 | # Provide either secret json or the path to the secret file 34 | GOOGLE_DRIVE_SERVICE_ACCOUNT_SECRET_JSON= 35 | GOOGLE_DRIVE_SERVICE_ACCOUNT_SECRET_FILE_PATH= 36 | 37 | # Dropbox (Optional) 38 | DROPBOX_ACCESS_TOKEN= 39 | DROPBOX_APP_KEY= 40 | DROPBOX_APP_SECRET= 41 | DROPBOX_REFRESH_TOKEN= 42 | 43 | # Nextcloud (Optional) 44 | NEXTCLOUD_EMAIL= 45 | NEXTCLOUD_PASSWORD= 46 | NEXTCLOUD_WEBDAV_URL= 47 | 48 | # pCloud (Optional) 49 | PCLOUD_ACCESS_TOKEN= 50 | PCLOUD_API_HOST= 51 | PCLOUD_FOLDER_ID= 52 | 53 | # if you don't use the Docker image and want to download the backup to a different folder 54 | # DOWNLOADS_DIRECTORY_PATH= 55 | 56 | ### Backup to Cloud With Docker 57 | 58 | Once you created your `.env` file, you can run the following command to start your backup: 59 | 60 | ```bash 61 | docker run \ 62 | --rm=true \ 63 | --env-file=.env \ 64 | ghcr.io/jckleiner/notion-backup 65 | ``` 66 | 67 | The downloaded Notion export file will be saved to the `/downloads` folder **within the Docker container** and the container 68 | will be removed after the backup is done (because of the `--rm=true` flag). 69 | 70 | If you want automatic backups in regular intervals, you could either set up a cronjob on your local machine or 71 | [fork this repo](#fork-github-actions) and let GitHub Actions do the job. 72 | 73 | ### Local Backup With Docker 74 | 75 | If you want to keep the downloaded files locally, you could mount the `/downloads` folder from the container somewhere 76 | on your machine: 77 | 78 | ```bash 79 | docker run \ 80 | --rm=true \ 81 | --env-file=.env \ 82 | -v :/downloads \ 83 | ghcr.io/jckleiner/notion-backup 84 | ``` 85 | 86 | If you want automatic backups in regular intervals, you could either set up a cronjob on your local machine or 87 | [fork this repo](#fork-github-actions) and let GitHub Actions do the job. 88 | 89 | ### Fork (GitHub Actions) 90 | 91 | Another way to do automated backups is using GitHub Actions. You can simply: 92 | 93 | 1. Fork this repository. 94 | 2. Create repository secrets: Go to `notion-backup (your forked repo) > Settings > Secrets > Actions` and create all 95 | the [necessary environment variables](#set-credentials). 96 | 3. Go to `notion-backup (your forked repo) > Actions` to see the workflows and make sure the 97 | `notion-backup-build-run` workflow is enabled. This is the workflow which will periodically build and run the 98 | application. 99 | 4. You can adjust when the action will be triggered by editing the `schedule > cron` property in your 100 | [notion-backup/.github/workflows/build-run.yml](.github/workflows/build-run.yml) 101 | workflow file (to convert time values into cron expressions: [crontab.guru](https://crontab.guru/)). 102 | 103 | That's it. GitHub Actions will now run your workflow regularly at your defined time interval. 104 | 105 | ## Troubleshooting 106 | 107 | ### Dropbox 108 | 109 | If you get the exception: `com.dropbox.core.BadResponseException: Bad JSON: expected object value.`, then try to 110 | re-generate your Dropbox access token and run the application again. 111 | 112 | ### pCloud 113 | 114 | If you get the exception: `com.pcloud.sdk.ApiError: 2094 - Invalid 'access_token' provided.`, 115 | please also make sure that the `PCLOUD_API_HOST` environment variable is correct. There are currently two API hosts: 116 | one for Europe (`eapi.pcloud.com`) and one for the rest (`api.pcloud.com`). 117 | If you still get the error, please try and regenerate the access token as described in the [pCloud section](./documentation/setup.md#pcloud) 118 | of the documentation. 119 | -------------------------------------------------------------------------------- /documentation/setup.md: -------------------------------------------------------------------------------- 1 | ### Find Your notion-space-id and token_v2 2 | 3 | 1. Login to your [notion profile](https://www.notion.so/login) 4 | 2. Open your developer console of your browser and go to the "Network" tab 5 | 3. Click on "Quick Find" on the Notion menu (should be at the upper left corner) and type something in the search bar 6 | 4. Typing will trigger a new request with the name `search` which should be visible under the **network tab**. Open that 7 | request and copy the value of `spaceId`. Paste it in your `.env` file as the value for `NOTION_SPACE_ID` 8 | 9 | ![testImage](../images/notion-search-request.png) 10 | 11 | 5. Go to **cookies tab** of that same requests and find the `token_v2` cookie. Paste it in your `.env` file as the value 12 | for `NOTION_TOKEN_V2`. This token (expires after one year and) is valid as long as you don't log out over the 13 | web-UI. Meaning that you can use it in this tool as long as you don't log out. If you do log out (or if the token 14 | expires after one year) then you need to log in again and fetch a new `token_v2` value. 15 | 16 | ### Dropbox 17 | 18 | 1. Create a new app on developer console (https://www.dropbox.com/developers/apps/create) 19 | 2. Go to the Permissions tab > enable `files.content.write` & `files.content.read` and click "Submit" to save your changes. 20 | Make sure you saved these changes **before** you generate your access token. 21 | 3. Go to the Settings tab > OAuth 2 > Generate access token > Generate. Note that these tokens are short-lived and expire after a few hours. 22 | Due to security reason long-lived tokens have been deprecated. 23 | 24 | For an automated setup, for example with [GitHub Action](../README.md#fork-github-actions), short-lived access tokens will not work because they will expire. 25 | An alternative solution would be to use a refresh token that does not expire which you need to retrieve manually once. 26 | The refresh token will then be used to fetch an access token on-the-fly everytime the application runs. 27 | Do the following steps to set up the refresh token flow. 28 | 29 | 1. Go to the Settings tab and store the App Key in the `DROPBOX_APP_KEY` environment variable 30 | 2. Go to the Settings tab and store the App Secret in the `DROPBOX_APP_SECRET` environment variable 31 | 3. Open a browser and enter the following URL and replace the `` placeholder 32 | 33 | https://www.dropbox.com/oauth2/authorize?client_id=&token_access_type=offline&response_type=code 34 | 35 | 4. Click on continue and allow the app to access your files 36 | 5. Copy the authorization code (referred to as `AUTH_CODE` in the following steps) shown in the following screen 37 | 6. Send the following HTTP POST request with the actual values replacing the placeholders. 38 | Note that you need to encode the string `:` as a BASE64 string. 39 | 40 | ``` 41 | curl --request POST \ 42 | --url https://api.dropboxapi.com/oauth2/token \ 43 | --header 'Authorization: Basic :>' \ 44 | --header 'Content-Type: application/x-www-form-urlencoded' \ 45 | --data code= \ 46 | --data grant_type=authorization_code 47 | ``` 48 | 49 | 7. Extract the `refresh_token` from the response and store it in the `DROPBOX_REFRESH_TOKEN` environment variable. 50 | **DO NOT SHARE THIS TOKEN WITH ANYONE!** 51 | 52 | 8. To ensure it works try sending the following HTTP POST request with the actual values replacing the placeholders 53 | 54 | ``` 55 | curl --request POST \ 56 | --url https://api.dropbox.com/oauth2/token \ 57 | --header 'Authorization: Basic :>' \ 58 | --header 'Content-Type: application/x-www-form-urlencoded' \ 59 | --data grant_type=refresh_token \ 60 | --data refresh_token= 61 | ``` 62 | 63 | 9. Leave the `DROPBOX_ACCESS_TOKEN` environment variable empty. 64 | Otherwise, the refresh token flow will be skipped and no new access token will be fetched. 65 | 66 | ### Nextcloud 67 | 68 | All you need is to provide your Email, password and the WebDAV URL. 69 | 70 | On your main Nextcloud page, click Settings at the bottom left. This should show you a WebDAV URL 71 | like `https://my.nextcloud.tld/remote.php/dav/files/EMAIL/path/to/directory/` 72 | 73 | * If the WebDAV URL ends with a `/`, for instance `https://my.nextcloud.tld/remote.php/dav/files/EMAIL/Documents/`: this 74 | indicates the uploaded file will be placed in the `Documents` folder. 75 | * If the WebDAV URL **does not end** with a `/`, for 76 | instance `https://my.nextcloud.tld/remote.php/dav/files/EMAIL/Documents/somefile.txt`: this indicates the uploaded 77 | file will be named `somefile.txt` and it will be placed in the `Documents` folder. If a file with the same name 78 | exists, it will be overwritten. 79 | * All the folders must be present 80 | 81 | ### Google Drive 82 | 83 | > This is a pretty tedious task. If someone knows a better way to do this, please let me know. 84 | 85 | 1. Login to your [google developer console](https://console.developers.google.com/) 86 | 2. Open [cloud-resource-manager](https://console.cloud.google.com/cloud-resource-manager) and create a project. 87 | 3. Go to the [Google Drive API](https://console.cloud.google.com/apis/library/drive.googleapis.com) page 88 | 4. Make sure in the top left corner you've selected your newly created project 89 | 5. Click "Enable" (if Google Driver API is already enabled for your project, you will see a "Manage" button. In that 90 | case you can continue with the next step) 91 | 6. Open the [credentials page](https://console.cloud.google.com/apis/credentials) 92 | 7. Click "Create credentials" and select "Service Account" 93 | 8. Give it a name and click "DONE" (You can ignore the other steps) 94 | 9. You will see your newly created service account E-Mail address under the "Service Accounts" section. Click on that 95 | account. 96 | 10. Copy the E-Mail address of your service account since you will need it later. 97 | 11. Keys -> Add Key -> Create new key -> JSON and download that file 98 | 12. Rename the downloaded file to `credentials.json` and move it to the project root directory. The path to this 99 | file is the value for your `GOOGLE_DRIVE_SERVICE_ACCOUNT_SECRET_FILE_PATH` environment variable. 100 | Alternatively, if you don't want to keep a `credentials.json` file, you could copy the contents of 101 | `credentials.json` file and provide it as a value to your `GOOGLE_DRIVE_SERVICE_ACCOUNT_SECRET_JSON` environment variable. 102 | 13. Login to your [Google Drive account](https://drive.google.com/drive/) and select the folder you want your notion 103 | backups to be saved in. You need to share that folder with the service account you've just created. Right click on 104 | the folder -> Share -> enter the E-Mail address of your service account. (The email of your service account will 105 | probably look something like `XXX@XXX.iam.gserviceaccount.com`. This is the value for your 106 | `GOOGLE_DRIVE_SERVICE_ACCOUNT` environment variable.) 107 | 14. Open that folder by double-clicking on it. You will now be able to see the id of that folder in the URL. It 108 | should look something like `https://drive.google.com/drive/folders/62F2faJbVasSGsYGyQzBeGSc2-k7GOZg2`. The ID 109 | (only the last part `62F2faJbVasSGsYGyQzBeGSc2-k7GOZg2` of the URL) is the value for 110 | your `GOOGLE_DRIVE_ROOT_FOLDER_ID` environment variable. 111 | 112 | ### pCloud 113 | 114 | 1. Go to the [pCloud Developer App Console](https://docs.pcloud.com/my_apps/) and create a new application. 115 | 2. Choose if you want the app to have only access to a specific app folder or your complete cloud and make sure to give it write access. 116 | 3. Open a browser and enter the following URL with `` replaced by the client ID shown in the developer console. 117 | 118 | ``` 119 | https://my.pcloud.com/oauth2/authorize?client_id=&response_type=code 120 | ``` 121 | 122 | 4. Log in to pCloud and allow the app access to your account 123 | 5. Copy the shown hostname into the `PCLOUD_API_HOST` environment variable 124 | 6. Copy the shown access code to some editor for later use (referred to as `ACCESS_CODE`) 125 | 7. Make the following HTTP GET request with the placeholders being replaced by the actual values 126 | 127 | ``` 128 | curl --request GET \ 129 | --url 'https:///oauth2_token?code=&client_id=&client_secret=' 130 | ``` 131 | 132 | 8. Copy the `access_token` value from the response into the `PCLOUD_ACCESS_TOKEN` environment variable 133 | 134 | If you want to upload files to the root of your app folder/cloud, then you are done here. 135 | Otherwise, if you want to upload files to a specific folder, do the following steps to find the folder ID of your target folder. 136 | 137 | 1. Open the [root directory](https://my.pcloud.com/#page=filemanager) of your cloud in the browser. 138 | 2. (Optional) Create the folder where you want to upload the files. 139 | Note that if you have chosen to give your app only access to a specific private app folder, 140 | go look for a folder with your app name in the `Applications` directory of the root directory of your cloud. 141 | By default, all files will be uploaded there and you don't need to specifically set the folder ID. 142 | However, if desired, you can also create another subfolder there. 143 | 3. Navigate to the exact folder where you want the files to be uploaded. 144 | 4. Look at the URL bar of your browser and copy the value of the `folder=` parameter 145 | into the `PCLOUD_FOLDER_ID` environment variable. -------------------------------------------------------------------------------- /ignored/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jckleiner/notion-backup/26bc0aa2d0c21a561d30540d7f81e304495534b8/ignored/.gitkeep -------------------------------------------------------------------------------- /images/notion-search-request.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jckleiner/notion-backup/26bc0aa2d0c21a561d30540d7f81e304495534b8/images/notion-search-request.png -------------------------------------------------------------------------------- /notion-api-response-structure.json: -------------------------------------------------------------------------------- 1 | // In progress 2 | { 3 | "results": [ 4 | { 5 | "id": "", 6 | "eventName": "exportSpace", 7 | "request": { 8 | "spaceId": "", 9 | "exportOptions": { 10 | "exportType": "markdown", 11 | "flattenExportFiletree": false, 12 | "timeZone": "Europe/Berlin", 13 | "locale": "en" 14 | } 15 | }, 16 | "actor": { 17 | "table": "notion_user", 18 | "id": "" 19 | }, 20 | "state": "in_progress", 21 | "rootRequest": { 22 | "eventName": "exportSpace", 23 | "requestId": "" 24 | }, 25 | "status": { 26 | "type": "progress", 27 | "pagesExported": 3 28 | } 29 | } 30 | ] 31 | } 32 | 33 | // Success 34 | { 35 | "results": [ 36 | { 37 | "id": "", 38 | "eventName": "exportSpace", 39 | "request": { 40 | "spaceId": "", 41 | "exportOptions": { 42 | "exportType": "markdown", 43 | "flattenExportFiletree": false, 44 | "timeZone": "Europe/Berlin", 45 | "locale": "en" 46 | } 47 | }, 48 | "actor": { 49 | "table": "notion_user", 50 | "id": "" 51 | }, 52 | "state": "success", 53 | "rootRequest": { 54 | "eventName": "exportSpace", 55 | "requestId": "" 56 | }, 57 | "status": { 58 | "type": "complete", 59 | "pagesExported": 25, 60 | "exportURL": "" 61 | } 62 | } 63 | ] 64 | } 65 | 66 | // failure 67 | { 68 | "results": [ 69 | { 70 | "id": "", 71 | "eventName": "exportSpace", 72 | "request": { 73 | "spaceId": "", 74 | "exportOptions": { 75 | "exportType": "MARKDOWN", 76 | "flattenExportFiletree": false, 77 | "timeZone": "Europe/Berlin", 78 | "locale": "en" 79 | } 80 | }, 81 | "actor": { 82 | "table": "notion_user", 83 | "id": "" 84 | }, 85 | "state": "failure", 86 | "rootRequest": { 87 | "eventName": "exportSpace", 88 | "requestId": "" 89 | }, 90 | "headers": { 91 | "ip": "" 92 | }, 93 | "equeuedAt": 1673994491013, 94 | "error": "Invalid input." 95 | } 96 | ] 97 | } -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | com.greydev 8 | notion-backup 9 | 1.0-SNAPSHOT 10 | 11 | 12 | 11 13 | 11 14 | UTF-8 15 | 16 | 1.18.22 17 | 18 | 19 | 20 | 21 | 22 | 23 | com.google.api-client 24 | google-api-client 25 | 1.23.0 26 | 27 | 28 | 29 | com.google.guava 30 | guava-jdk5 31 | 32 | 33 | 34 | 35 | 36 | com.google.oauth-client 37 | google-oauth-client-jetty 38 | 1.23.0 39 | 40 | 41 | 42 | com.google.apis 43 | google-api-services-drive 44 | v3-rev197-1.25.0 45 | 46 | 47 | 48 | com.google.auth 49 | google-auth-library-oauth2-http 50 | 0.23.0 51 | 52 | 53 | 54 | 55 | 56 | com.fasterxml.jackson.core 57 | jackson-databind 58 | 2.12.0 59 | 60 | 61 | 62 | 63 | io.github.cdimascio 64 | dotenv-java 65 | 2.2.0 66 | 67 | 68 | 69 | 70 | org.apache.commons 71 | commons-lang3 72 | 3.11 73 | 74 | 75 | 76 | 77 | commons-io 78 | commons-io 79 | 2.8.0 80 | 81 | 82 | 83 | 84 | org.slf4j 85 | slf4j-api 86 | 1.7.30 87 | 88 | 89 | 90 | 91 | ch.qos.logback 92 | logback-classic 93 | 1.2.3 94 | 95 | 96 | 97 | 98 | 99 | com.dropbox.core 100 | dropbox-core-sdk 101 | 3.1.5 102 | 103 | 104 | 105 | 106 | com.pcloud.sdk 107 | java-core 108 | 1.8.1 109 | 110 | 111 | 112 | 113 | org.projectlombok 114 | lombok 115 | ${lombok-version} 116 | 117 | 118 | 119 | 120 | 121 | 122 | org.assertj 123 | assertj-core 124 | 3.19.0 125 | test 126 | 127 | 128 | 129 | 130 | org.junit.jupiter 131 | junit-jupiter 132 | 5.7.1 133 | test 134 | 135 | 136 | 137 | 138 | org.mockito 139 | mockito-core 140 | 3.7.7 141 | test 142 | 143 | 144 | 145 | 146 | org.mockito 147 | mockito-junit-jupiter 148 | 3.7.7 149 | test 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | org.apache.maven.plugins 160 | maven-jar-plugin 161 | 3.2.0 162 | 163 | 164 | 165 | true 166 | lib/ 167 | com.greydev.notionbackup.NotionBackup 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | org.apache.maven.plugins 176 | maven-shade-plugin 177 | 3.2.4 178 | 179 | 180 | package 181 | 182 | shade 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | org.apache.maven.plugins 191 | maven-compiler-plugin 192 | 3.6.1 193 | 194 | 195 | 196 | 197 | org.projectlombok 198 | lombok 199 | ${lombok-version} 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | org.apache.maven.plugins 208 | maven-surefire-plugin 209 | 2.22.2 210 | 211 | 212 | 213 | 214 | 215 | -------------------------------------------------------------------------------- /src/main/java/com/greydev/notionbackup/NotionBackup.java: -------------------------------------------------------------------------------- 1 | package com.greydev.notionbackup; 2 | 3 | import java.io.File; 4 | import java.io.IOException; 5 | import java.rmi.UnexpectedException; 6 | import java.util.Optional; 7 | import java.util.concurrent.CompletableFuture; 8 | import java.util.concurrent.atomic.AtomicBoolean; 9 | 10 | import com.greydev.notionbackup.cloudstorage.pcloud.PCloudApiClientFactory; 11 | import com.greydev.notionbackup.cloudstorage.pcloud.PCloudClient; 12 | import com.pcloud.sdk.ApiClient; 13 | import com.pcloud.sdk.RemoteFolder; 14 | import org.apache.commons.codec.CharEncoding; 15 | import org.apache.commons.io.FileUtils; 16 | import org.apache.commons.lang3.StringUtils; 17 | 18 | import com.dropbox.core.v2.DbxClientV2; 19 | import com.dropbox.core.DbxException; 20 | import com.dropbox.core.DbxRequestConfig; 21 | import com.dropbox.core.oauth.DbxCredential; 22 | import com.dropbox.core.oauth.DbxRefreshResult; 23 | import com.google.api.services.drive.Drive; 24 | import com.greydev.notionbackup.cloudstorage.dropbox.DropboxClient; 25 | import com.greydev.notionbackup.cloudstorage.dropbox.DropboxServiceFactory; 26 | import com.greydev.notionbackup.cloudstorage.googledrive.GoogleDriveClient; 27 | import com.greydev.notionbackup.cloudstorage.googledrive.GoogleDriveServiceFactory; 28 | import com.greydev.notionbackup.cloudstorage.nextcloud.NextcloudClient; 29 | 30 | import io.github.cdimascio.dotenv.Dotenv; 31 | import lombok.extern.slf4j.Slf4j; 32 | 33 | 34 | @Slf4j 35 | public class NotionBackup { 36 | 37 | public static final String KEY_DROPBOX_ACCESS_TOKEN = "DROPBOX_ACCESS_TOKEN"; 38 | public static final String KEY_DROPBOX_APP_KEY = "DROPBOX_APP_KEY"; 39 | public static final String KEY_DROPBOX_APP_SECRET = "DROPBOX_APP_SECRET"; 40 | public static final String KEY_DROPBOX_REFRESH_TOKEN = "DROPBOX_REFRESH_TOKEN"; 41 | 42 | public static final String KEY_NEXTCLOUD_EMAIL = "NEXTCLOUD_EMAIL"; 43 | public static final String KEY_NEXTCLOUD_PASSWORD = "NEXTCLOUD_PASSWORD"; 44 | public static final String KEY_NEXTCLOUD_WEBDAV_URL = "NEXTCLOUD_WEBDAV_URL"; 45 | 46 | private static final String KEY_GOOGLE_DRIVE_ROOT_FOLDER_ID = "GOOGLE_DRIVE_ROOT_FOLDER_ID"; 47 | private static final String KEY_GOOGLE_DRIVE_SERVICE_ACCOUNT_SECRET_JSON = "GOOGLE_DRIVE_SERVICE_ACCOUNT_SECRET_JSON"; 48 | private static final String KEY_GOOGLE_DRIVE_SERVICE_ACCOUNT_SECRET_FILE_PATH = "GOOGLE_DRIVE_SERVICE_ACCOUNT_SECRET_FILE_PATH"; 49 | 50 | private static final String KEY_PCLOUD_ACCESS_TOKEN = "PCLOUD_ACCESS_TOKEN"; 51 | private static final String KEY_PCLOUD_API_HOST = "PCLOUD_API_HOST"; 52 | private static final String KEY_PCLOUD_FOLDER_ID = "PCLOUD_FOLDER_ID"; 53 | 54 | private static final Dotenv dotenv; 55 | 56 | static { 57 | dotenv = initDotenv(); 58 | } 59 | 60 | public static void main(String[] args) { 61 | log.info("---------------- Starting Notion Backup ----------------"); 62 | 63 | NotionClient notionClient = new NotionClient(dotenv); 64 | 65 | final File exportedFile = notionClient.export() 66 | .orElseThrow(() -> new IllegalStateException("Could not export notion file")); 67 | 68 | // use a local file to skip the notion export step 69 | // final File exportedFile = new File("notion-export-markdown_2022-01-18_23-17-13.zip"); 70 | 71 | AtomicBoolean hasErrorOccurred = new AtomicBoolean(false); 72 | 73 | CompletableFuture futureGoogleDrive = CompletableFuture 74 | .runAsync(() -> NotionBackup.startGoogleDriveBackup(exportedFile)) 75 | .handle((result, ex) -> { 76 | if (ex != null) { 77 | log.error("Exception while GoogleDrive upload", ex); 78 | hasErrorOccurred.set(true); 79 | } 80 | return null; 81 | }); 82 | 83 | CompletableFuture futureDropbox = CompletableFuture 84 | .runAsync(() -> NotionBackup.startDropboxBackup(exportedFile)) 85 | .handle((result, ex) -> { 86 | if (ex != null) { 87 | hasErrorOccurred.set(true); 88 | log.error("Exception while Dropbox upload", ex); 89 | } 90 | return null; 91 | }); 92 | 93 | CompletableFuture futureNextcloud = CompletableFuture 94 | .runAsync(() -> NotionBackup.startNextcloudBackup(exportedFile)) 95 | .handle((result, ex) -> { 96 | if (ex != null) { 97 | hasErrorOccurred.set(true); 98 | log.error("Exception while Nextcloud upload", ex); 99 | } 100 | return null; 101 | }); 102 | 103 | CompletableFuture futurePCloud = CompletableFuture 104 | .runAsync(() -> NotionBackup.startPCloudBackup(exportedFile)) 105 | .handle((result, ex) -> { 106 | if (ex != null) { 107 | hasErrorOccurred.set(true); 108 | log.error("Exception while Pcloud upload", ex); 109 | } 110 | return null; 111 | }); 112 | 113 | CompletableFuture.allOf(futureGoogleDrive, futureDropbox, futureNextcloud, futurePCloud).join(); 114 | 115 | if (hasErrorOccurred.get()) { 116 | log.error("Not all backups were completed successfully. See the logs above to get more information about the errors."); 117 | System.exit(1); 118 | } 119 | } 120 | 121 | 122 | public static void startGoogleDriveBackup(File fileToUpload) { 123 | Optional serviceAccountSecretOptional = extractGoogleServiceAccountSecret(); 124 | if (serviceAccountSecretOptional.isEmpty()) { 125 | log.info("Skipping Google Drive upload. No secret provided for Google Drive."); 126 | return; 127 | } 128 | Optional googleServiceOptional = GoogleDriveServiceFactory.create(serviceAccountSecretOptional.get()); 129 | if (googleServiceOptional.isEmpty()) { 130 | log.warn("Skipping Google Drive upload. Could not create Google Drive service."); 131 | return; 132 | } 133 | String googleDriveRootFolderId = dotenv.get(KEY_GOOGLE_DRIVE_ROOT_FOLDER_ID); 134 | if (StringUtils.isBlank(googleDriveRootFolderId)) { 135 | log.info("Skipping Google Drive upload. {} is blank.", KEY_GOOGLE_DRIVE_ROOT_FOLDER_ID); 136 | return; 137 | } 138 | GoogleDriveClient GoogleDriveClient = new GoogleDriveClient(googleServiceOptional.get(), googleDriveRootFolderId); 139 | boolean isSuccess = GoogleDriveClient.upload(fileToUpload); 140 | 141 | if (!isSuccess) { 142 | throw new IllegalStateException("Backup was not successful"); 143 | } 144 | } 145 | 146 | 147 | public static void startDropboxBackup(File fileToUpload) { 148 | Optional dropboxAccessToken = getDropboxAccessToken(); 149 | if (dropboxAccessToken.isEmpty()) { 150 | log.info("No Dropbox access token available. Skipping Dropbox upload."); 151 | return; 152 | } 153 | 154 | Optional dropboxServiceOptional = DropboxServiceFactory.create(dropboxAccessToken.get()); 155 | if (dropboxServiceOptional.isEmpty()) { 156 | log.warn("Could not create Dropbox service. Skipping Dropbox upload"); 157 | return; 158 | } 159 | DropboxClient dropboxClient = new DropboxClient(dropboxServiceOptional.get()); 160 | boolean isSuccess = dropboxClient.upload(fileToUpload); 161 | 162 | if (!isSuccess) { 163 | throw new IllegalStateException("Backup was not successful"); 164 | } 165 | } 166 | 167 | private static Optional getDropboxAccessToken() { 168 | String dropboxAccessToken = dotenv.get(KEY_DROPBOX_ACCESS_TOKEN); 169 | 170 | if (StringUtils.isBlank(dropboxAccessToken)) { 171 | log.info("{} is blank. Trying to fetch an access token with the refresh token...", KEY_DROPBOX_ACCESS_TOKEN); 172 | 173 | String dropboxAppKey = dotenv.get(KEY_DROPBOX_APP_KEY); 174 | String dropboxAppSecret = dotenv.get(KEY_DROPBOX_APP_SECRET); 175 | String dropboxRefreshToken = dotenv.get(KEY_DROPBOX_REFRESH_TOKEN); 176 | if (StringUtils.isAnyBlank(dropboxAppKey, dropboxAppSecret, dropboxRefreshToken)) { 177 | log.info("Failed to fetch an access token. Either {}, {} or {} is blank.", KEY_DROPBOX_REFRESH_TOKEN, KEY_DROPBOX_APP_KEY, KEY_DROPBOX_APP_SECRET); 178 | return Optional.empty(); 179 | } 180 | 181 | DbxCredential dbxCredential = new DbxCredential("", 14400L, dropboxRefreshToken, dropboxAppKey, dropboxAppSecret); 182 | DbxRefreshResult refreshResult; 183 | try { 184 | refreshResult = dbxCredential.refresh(new DbxRequestConfig("NotionBackup")); 185 | } catch (DbxException e) { 186 | log.info("Token refresh call to Dropbox API failed."); 187 | return Optional.empty(); 188 | } 189 | 190 | dropboxAccessToken = refreshResult.getAccessToken(); 191 | log.info("Successfully fetched an access token."); 192 | } 193 | 194 | return Optional.of(dropboxAccessToken); 195 | } 196 | 197 | 198 | public static void startNextcloudBackup(File fileToUpload) { 199 | String email = dotenv.get(KEY_NEXTCLOUD_EMAIL); 200 | String password = dotenv.get(KEY_NEXTCLOUD_PASSWORD); 201 | String webdavUrl = dotenv.get(KEY_NEXTCLOUD_WEBDAV_URL); 202 | 203 | if (StringUtils.isAnyBlank(email, password, webdavUrl)) { 204 | log.info("Skipping Nextcloud upload. {}, {} or {} is blank.", KEY_NEXTCLOUD_EMAIL, KEY_NEXTCLOUD_PASSWORD, KEY_NEXTCLOUD_WEBDAV_URL); 205 | return; 206 | } 207 | 208 | boolean isSuccess = new NextcloudClient(email, password, webdavUrl).upload(fileToUpload); 209 | 210 | if (!isSuccess) { 211 | throw new IllegalStateException("Backup was not successful"); 212 | } 213 | } 214 | 215 | public static void startPCloudBackup(File fileToUpload) { 216 | String pCloudAccessToken = dotenv.get(KEY_PCLOUD_ACCESS_TOKEN); 217 | String pCloudApiHost = dotenv.get(KEY_PCLOUD_API_HOST); 218 | 219 | if (StringUtils.isAnyBlank(pCloudAccessToken, pCloudApiHost)) { 220 | log.info("Skipping pCloud upload. {} or {} is blank.", KEY_PCLOUD_ACCESS_TOKEN, KEY_PCLOUD_API_HOST); 221 | return; 222 | } 223 | 224 | Optional pCloudApiClient = PCloudApiClientFactory.create(pCloudAccessToken, pCloudApiHost); 225 | if (pCloudApiClient.isEmpty()) { 226 | log.info("Could not create pCloud API client. Skipping pCloud upload."); 227 | return; 228 | } 229 | 230 | String pCloudFolderIdString = dotenv.get(KEY_PCLOUD_FOLDER_ID); 231 | long pCloudFolderId = RemoteFolder.ROOT_FOLDER_ID; 232 | if (StringUtils.isNotBlank(pCloudFolderIdString)) { 233 | try { 234 | pCloudFolderId = Long.parseLong(pCloudFolderIdString); 235 | } catch (NumberFormatException e) { 236 | log.warn("The given pCloud folder ID {} is not a valid number. Skipping pCloud upload.", pCloudFolderIdString); 237 | return; 238 | } 239 | } 240 | PCloudClient pCloudClient = new PCloudClient(pCloudApiClient.get(), pCloudFolderId); 241 | boolean isSuccess = pCloudClient.upload(fileToUpload); 242 | 243 | if (!isSuccess) { 244 | throw new IllegalStateException("Backup was not successful"); 245 | } 246 | } 247 | 248 | private static Optional extractGoogleServiceAccountSecret() { 249 | String serviceAccountSecret = dotenv.get(KEY_GOOGLE_DRIVE_SERVICE_ACCOUNT_SECRET_JSON); 250 | String serviceAccountSecretFilePath = dotenv.get(KEY_GOOGLE_DRIVE_SERVICE_ACCOUNT_SECRET_FILE_PATH); 251 | 252 | // Use the secret value if provided. 253 | if (StringUtils.isNotBlank(serviceAccountSecret)) { 254 | return Optional.of(serviceAccountSecret); 255 | } 256 | // If not then read the value from the given path to the file which contains the secret 257 | if (StringUtils.isNotBlank(serviceAccountSecretFilePath)) { 258 | return readFileContentAsString(serviceAccountSecretFilePath); 259 | } 260 | log.info("Both {} and {} are empty", KEY_GOOGLE_DRIVE_SERVICE_ACCOUNT_SECRET_JSON, KEY_GOOGLE_DRIVE_SERVICE_ACCOUNT_SECRET_FILE_PATH); 261 | return Optional.empty(); 262 | } 263 | 264 | 265 | private static Optional readFileContentAsString(String filePath) { 266 | String fileContent = null; 267 | try { 268 | fileContent = FileUtils.readFileToString(new File(filePath), CharEncoding.UTF_8); 269 | } catch (IOException e) { 270 | log.warn("IOException while reading file in path: '{}'", filePath, e); 271 | return Optional.empty(); 272 | } 273 | if (StringUtils.isBlank(fileContent)) { 274 | log.warn("File '{}' is empty", filePath); 275 | return Optional.empty(); 276 | } 277 | return Optional.of(fileContent); 278 | } 279 | 280 | 281 | private static Dotenv initDotenv() { 282 | Dotenv dotenv = Dotenv.configure() 283 | .ignoreIfMissing() 284 | .ignoreIfMalformed() 285 | .load(); 286 | if (dotenv == null) { 287 | throw new IllegalStateException("Could not load dotenv!"); 288 | } 289 | return dotenv; 290 | } 291 | 292 | } -------------------------------------------------------------------------------- /src/main/java/com/greydev/notionbackup/NotionClient.java: -------------------------------------------------------------------------------- 1 | package com.greydev.notionbackup; 2 | 3 | import java.io.File; 4 | import java.io.IOException; 5 | import java.net.URI; 6 | import java.net.http.HttpClient; 7 | import java.net.http.HttpRequest; 8 | import java.net.http.HttpResponse; 9 | import java.nio.file.Path; 10 | import java.time.LocalDateTime; 11 | import java.time.format.DateTimeFormatter; 12 | import java.util.Optional; 13 | 14 | import org.apache.commons.lang3.StringUtils; 15 | 16 | import com.fasterxml.jackson.databind.JsonNode; 17 | import com.fasterxml.jackson.databind.ObjectMapper; 18 | 19 | import io.github.cdimascio.dotenv.Dotenv; 20 | import lombok.extern.slf4j.Slf4j; 21 | 22 | 23 | @Slf4j 24 | public class NotionClient { 25 | 26 | private static final int FETCH_DOWNLOAD_URL_RETRY_SECONDS = 5; 27 | 28 | private static final String ENQUEUE_ENDPOINT = "https://www.notion.so/api/v3/enqueueTask"; 29 | private static final String NOTIFICATION_ENDPOINT = "https://www.notion.so/api/v3/getNotificationLogV2"; 30 | private static final String TOKEN_V2 = "token_v2"; 31 | private static final String EXPORT_FILE_NAME = "notion-export"; 32 | private static final String EXPORT_FILE_EXTENSION = ".zip"; 33 | 34 | private static final String KEY_DOWNLOADS_DIRECTORY_PATH = "DOWNLOADS_DIRECTORY_PATH"; 35 | private static final String KEY_NOTION_SPACE_ID = "NOTION_SPACE_ID"; 36 | private static final String KEY_NOTION_TOKEN_V2 = "NOTION_TOKEN_V2"; 37 | private static final String KEY_NOTION_EXPORT_TYPE = "NOTION_EXPORT_TYPE"; 38 | private static final String KEY_NOTION_FLATTEN_EXPORT_FILETREE = "NOTION_FLATTEN_EXPORT_FILETREE"; 39 | private static final String KEY_NOTION_EXPORT_COMMENTS = "NOTION_EXPORT_COMMENTS"; 40 | private static final String DEFAULT_NOTION_EXPORT_TYPE = "markdown"; 41 | private static final boolean DEFAULT_NOTION_FLATTEN_EXPORT_FILETREE = false; 42 | private static final boolean DEFAULT_NOTION_EXPORT_COMMENTS = true; 43 | private static final String DEFAULT_DOWNLOADS_PATH = "/downloads"; 44 | 45 | private final String notionSpaceId; 46 | private final String notionTokenV2; 47 | private final String exportType; 48 | private final boolean flattenExportFileTree; 49 | private final boolean exportComments; 50 | private String downloadsDirectoryPath; 51 | 52 | private final HttpClient client; 53 | private final ObjectMapper objectMapper = new ObjectMapper(); 54 | 55 | 56 | NotionClient(Dotenv dotenv) { 57 | this.client = HttpClient.newBuilder().build(); 58 | 59 | // both environment variables and variables defined in the .env file can be accessed this way 60 | notionSpaceId = dotenv.get(KEY_NOTION_SPACE_ID); 61 | notionTokenV2 = dotenv.get(KEY_NOTION_TOKEN_V2); 62 | downloadsDirectoryPath = dotenv.get(KEY_DOWNLOADS_DIRECTORY_PATH); 63 | 64 | if (StringUtils.isBlank(downloadsDirectoryPath)) { 65 | log.info("{} is not set. Downloads will be saved to: {} ", KEY_DOWNLOADS_DIRECTORY_PATH, DEFAULT_DOWNLOADS_PATH); 66 | downloadsDirectoryPath = DEFAULT_DOWNLOADS_PATH; 67 | } else { 68 | log.info("Downloads will be saved to: {} ", downloadsDirectoryPath); 69 | } 70 | 71 | exportType = StringUtils.isNotBlank(dotenv.get(KEY_NOTION_EXPORT_TYPE)) ? dotenv.get(KEY_NOTION_EXPORT_TYPE) : DEFAULT_NOTION_EXPORT_TYPE; 72 | flattenExportFileTree = StringUtils.isNotBlank(dotenv.get(KEY_NOTION_FLATTEN_EXPORT_FILETREE)) ? 73 | Boolean.parseBoolean(dotenv.get(KEY_NOTION_FLATTEN_EXPORT_FILETREE)) : 74 | DEFAULT_NOTION_FLATTEN_EXPORT_FILETREE; 75 | exportComments = StringUtils.isNotBlank(dotenv.get(KEY_NOTION_EXPORT_COMMENTS)) ? 76 | Boolean.parseBoolean(dotenv.get(KEY_NOTION_EXPORT_COMMENTS)) : 77 | DEFAULT_NOTION_EXPORT_COMMENTS; 78 | 79 | exitIfRequiredEnvVariablesNotValid(); 80 | 81 | log.info("Using export type: {}", exportType); 82 | log.info("Flatten export file tree: {}", flattenExportFileTree); 83 | log.info("Export comments: {}", exportComments); 84 | } 85 | 86 | 87 | private void exitIfRequiredEnvVariablesNotValid() { 88 | if (StringUtils.isBlank(notionSpaceId)) { 89 | exit(KEY_NOTION_SPACE_ID + " is missing!"); 90 | } 91 | if (StringUtils.isBlank(notionTokenV2)) { 92 | exit(KEY_NOTION_TOKEN_V2 + " is missing!"); 93 | } 94 | } 95 | 96 | 97 | public Optional export() { 98 | try { 99 | long exportTriggerTimestamp = System.currentTimeMillis(); 100 | // TODO - taskId is not really needed anymore 101 | Optional taskId = triggerExportTask(); 102 | 103 | if (taskId.isEmpty()) { 104 | log.info("taskId could not be extracted"); 105 | return Optional.empty(); 106 | } 107 | log.info("taskId extracted"); 108 | 109 | Optional downloadLink = fetchDownloadUrl(exportTriggerTimestamp); 110 | if (downloadLink.isEmpty()) { 111 | log.info("downloadLink could not be extracted"); 112 | return Optional.empty(); 113 | } 114 | log.info("Download link extracted"); 115 | 116 | log.info("Downloading file..."); 117 | String fileName = String.format("%s-%s%s_%s%s", 118 | EXPORT_FILE_NAME, 119 | exportType, 120 | flattenExportFileTree ? "-flattened" : "", 121 | LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")), 122 | EXPORT_FILE_EXTENSION); 123 | 124 | log.info("Downloaded export will be saved to: " + downloadsDirectoryPath); 125 | log.info("fileName: " + fileName); 126 | Path downloadPath = Path.of(downloadsDirectoryPath, fileName); 127 | Optional downloadedFile = downloadToFile(downloadLink.get(), downloadPath); 128 | 129 | if (downloadedFile.isEmpty() || !downloadedFile.get().isFile()) { 130 | log.info("Could not download file"); 131 | return Optional.empty(); 132 | } 133 | 134 | log.info("Download finished: {}", downloadedFile.get().getName()); 135 | return downloadedFile; 136 | } catch (IOException | InterruptedException e) { 137 | log.warn("Exception during export", e); 138 | } 139 | return Optional.empty(); 140 | } 141 | 142 | 143 | private Optional downloadToFile(String url, Path downloadPath) { 144 | HttpRequest request = HttpRequest.newBuilder() 145 | .uri(URI.create(url)) 146 | .GET() 147 | .build(); 148 | 149 | try { 150 | log.info("Downloading file to: '{}'", downloadPath); 151 | client.send(request, HttpResponse.BodyHandlers.ofFile(downloadPath)); 152 | return Optional.of(downloadPath.toFile()); 153 | } catch (IOException | InterruptedException e) { 154 | log.warn("Exception during file download", e); 155 | return Optional.empty(); 156 | } 157 | } 158 | 159 | 160 | private Optional triggerExportTask() throws IOException, InterruptedException { 161 | HttpRequest request = HttpRequest.newBuilder() 162 | .uri(URI.create(ENQUEUE_ENDPOINT)) 163 | .header("Cookie", TOKEN_V2 + "=" + notionTokenV2) 164 | .header("Content-Type", "application/json") 165 | .POST(HttpRequest.BodyPublishers.ofString(getTaskJson())) 166 | .build(); 167 | 168 | HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); 169 | 170 | JsonNode responseJsonNode = objectMapper.readTree(response.body()); 171 | 172 | /* This will be the response if the given token is not valid anymore (for example if a logout occurred) 173 | { 174 | "errorId": "", 175 | "name":"UnauthorizedError", 176 | "message":"Token was invalid or expired.", 177 | "clientData":{"type":"login_try_again"} 178 | } 179 | */ 180 | if (responseJsonNode.get("taskId") == null) { 181 | JsonNode errorName = responseJsonNode.get("name"); 182 | log.error("Error name: {}, error message: {}", errorName, responseJsonNode.get("message")); 183 | if (StringUtils.equalsIgnoreCase(errorName.toString(), "UnauthorizedError")) { 184 | log.error("UnauthorizedError: seems like your token is not valid anymore. Try to log in to Notion again and replace you old token."); 185 | } 186 | return Optional.empty(); 187 | } 188 | 189 | return Optional.of(responseJsonNode.get("taskId").asText()); 190 | } 191 | 192 | private Optional fetchDownloadUrl(long exportTriggerTimestamp) throws IOException, InterruptedException { 193 | try { 194 | for (int i = 0; i < 500; i++) { 195 | sleep(FETCH_DOWNLOAD_URL_RETRY_SECONDS); 196 | 197 | HttpRequest request = HttpRequest.newBuilder() 198 | .uri(URI.create(NOTIFICATION_ENDPOINT)) 199 | .header("Cookie", TOKEN_V2 + "=" + notionTokenV2) 200 | .header("Content-Type", "application/json") 201 | .POST(HttpRequest.BodyPublishers.ofString(getNotificationJson())) 202 | .build(); 203 | 204 | HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); 205 | JsonNode rootNode = objectMapper.readTree(response.body()); 206 | 207 | JsonNode node = rootNode.path("recordMap"); 208 | node = node.path("activity"); 209 | node = node.fields().next().getValue(); 210 | node = node.path("value"); 211 | 212 | long notificationStartTimestamp = Long.parseLong(node.path("start_time").asText()); 213 | 214 | // we want the notification newer than the export trigger timestamp 215 | // since the Notion response also contains older export trigger notifications 216 | if (notificationStartTimestamp < exportTriggerTimestamp) { 217 | log.info("The newest export trigger notification is still not in the Notion response. " + 218 | "Trying again in {} seconds...", FETCH_DOWNLOAD_URL_RETRY_SECONDS); 219 | continue; 220 | } 221 | log.info("Found a new export trigger notification in the Notion response. " + 222 | "Attempting to extract the download URL. " + 223 | "Timestamp of when the export was triggered: {}. " + 224 | "Timestamp of the notification: {}", exportTriggerTimestamp, notificationStartTimestamp); 225 | 226 | node = node.path("edits"); 227 | node = node.get(0); 228 | JsonNode exportActivity = node.path("link"); 229 | 230 | if (exportActivity.isMissingNode()) { 231 | log.info("The download URL is not yet present. Trying again in {} seconds...", FETCH_DOWNLOAD_URL_RETRY_SECONDS); 232 | continue; 233 | } 234 | return Optional.of(exportActivity.textValue()); 235 | } 236 | } 237 | catch (Exception e) { 238 | log.error("An exception occurred: ", e); 239 | } 240 | return Optional.empty(); 241 | } 242 | 243 | private String getTaskJson() { 244 | String taskJsonTemplate = "{" + 245 | " \"task\": {" + 246 | " \"eventName\": \"exportSpace\"," + 247 | " \"request\": {" + 248 | " \"spaceId\": \"%s\"," + 249 | " \"shouldExportComments\": %s," + 250 | " \"exportOptions\": {" + 251 | " \"exportType\": \"%s\"," + 252 | " \"flattenExportFiletree\": %s," + 253 | " \"timeZone\": \"Europe/Berlin\"," + 254 | " \"locale\": \"en\"" + 255 | " }" + 256 | " }" + 257 | " }" + 258 | "}"; 259 | return String.format(taskJsonTemplate, notionSpaceId, exportComments, exportType.toLowerCase(), flattenExportFileTree); 260 | } 261 | 262 | private String getNotificationJson() { 263 | String notificationJsonTemplate = "{" + 264 | " \"spaceId\": \"%s\"," + 265 | " \"size\": 20," + 266 | " \"type\": \"unread_and_read\"," + 267 | " \"variant\": \"no_grouping\"" + 268 | "}"; 269 | return String.format(notificationJsonTemplate, notionSpaceId); 270 | } 271 | 272 | private void exit(String message) { 273 | log.error(message); 274 | System.exit(1); 275 | } 276 | 277 | 278 | private void sleep(int seconds) { 279 | try { 280 | Thread.sleep(seconds * 1000); 281 | } catch (InterruptedException e) { 282 | log.error("An exception occurred: ", e); 283 | Thread.currentThread().interrupt(); 284 | } 285 | } 286 | 287 | } 288 | -------------------------------------------------------------------------------- /src/main/java/com/greydev/notionbackup/cloudstorage/CloudStorageClient.java: -------------------------------------------------------------------------------- 1 | package com.greydev.notionbackup.cloudstorage; 2 | 3 | import java.io.File; 4 | 5 | 6 | public interface CloudStorageClient { 7 | 8 | boolean upload(File fileToUpload); 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/greydev/notionbackup/cloudstorage/dropbox/DropboxClient.java: -------------------------------------------------------------------------------- 1 | package com.greydev.notionbackup.cloudstorage.dropbox; 2 | 3 | import java.io.File; 4 | import java.io.FileInputStream; 5 | import java.io.IOException; 6 | import java.io.InputStream; 7 | 8 | import org.apache.commons.lang3.StringUtils; 9 | 10 | import com.dropbox.core.DbxException; 11 | import com.dropbox.core.v2.DbxClientV2; 12 | import com.dropbox.core.v2.files.ListFolderResult; 13 | import com.greydev.notionbackup.cloudstorage.CloudStorageClient; 14 | 15 | import lombok.extern.slf4j.Slf4j; 16 | 17 | 18 | @Slf4j 19 | public class DropboxClient implements CloudStorageClient { 20 | 21 | /* 22 | When you want to be able to define the value of a private field, you have two options: 23 | 1. The first one is to create a constructor that receives the value to be set and 24 | 2. The second one is to create a setter for such private field. 25 | 3. (BAD) Set field with reflection 26 | */ 27 | private final DbxClientV2 dropboxService; 28 | 29 | 30 | public DropboxClient(DbxClientV2 dropboxService) { 31 | this.dropboxService = dropboxService; 32 | } 33 | 34 | 35 | @Override 36 | public boolean upload(File fileToUpload) { 37 | log.info("Dropbox: uploading file '{}' ...", fileToUpload.getName()); 38 | try (InputStream in = new FileInputStream(fileToUpload)) { 39 | // This method does not override if it's the same file with the same name and silently executes 40 | // Throws an UploadErrorException if we try to upload a different file with an already existing name 41 | // without slash: IllegalArgumentException: String 'path' does not match pattern 42 | dropboxService.files().uploadBuilder("/" + fileToUpload.getName()).uploadAndFinish(in); 43 | 44 | if (doesFileExist(fileToUpload.getName())) { 45 | log.info("Dropbox: successfully uploaded '{}'", fileToUpload.getName()); 46 | return true; 47 | } else { 48 | log.warn("Dropbox: could not upload '{}'", fileToUpload.getName()); 49 | } 50 | } catch (IOException | DbxException e) { 51 | log.warn("Dropbox: exception during upload of file '{}'", fileToUpload.getName(), e); 52 | } 53 | return false; 54 | } 55 | 56 | 57 | public boolean doesFileExist(String fileName) throws DbxException { 58 | ListFolderResult result = dropboxService.files().listFolder(""); 59 | return result.getEntries().stream() 60 | .anyMatch(entry -> StringUtils.equalsIgnoreCase(entry.getName(), fileName)); 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/com/greydev/notionbackup/cloudstorage/dropbox/DropboxServiceFactory.java: -------------------------------------------------------------------------------- 1 | package com.greydev.notionbackup.cloudstorage.dropbox; 2 | 3 | import java.util.Optional; 4 | 5 | import org.apache.commons.lang3.StringUtils; 6 | 7 | import com.dropbox.core.DbxRequestConfig; 8 | import com.dropbox.core.v2.DbxClientV2; 9 | 10 | import lombok.extern.slf4j.Slf4j; 11 | 12 | 13 | @Slf4j 14 | public class DropboxServiceFactory { 15 | 16 | public static Optional create(String dropboxAccessToken) { 17 | if (StringUtils.isBlank(dropboxAccessToken)) { 18 | log.warn("The given dropboxAccessToken is blank."); 19 | return Optional.empty(); 20 | } 21 | DbxClientV2 service = null; 22 | try { 23 | DbxRequestConfig config = DbxRequestConfig.newBuilder("dropbox/notion-backup").build(); 24 | service = new DbxClientV2(config, dropboxAccessToken); 25 | } catch (Exception e) { 26 | log.warn("An exception occurred: ", e); 27 | } 28 | return Optional.ofNullable(service); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/greydev/notionbackup/cloudstorage/googledrive/GoogleDriveClient.java: -------------------------------------------------------------------------------- 1 | package com.greydev.notionbackup.cloudstorage.googledrive; 2 | 3 | import java.io.IOException; 4 | import java.util.Collections; 5 | 6 | import com.google.api.client.http.FileContent; 7 | import com.google.api.services.drive.Drive; 8 | import com.google.api.services.drive.model.File; 9 | import com.greydev.notionbackup.cloudstorage.CloudStorageClient; 10 | 11 | import lombok.extern.slf4j.Slf4j; 12 | 13 | 14 | @Slf4j 15 | public class GoogleDriveClient implements CloudStorageClient { 16 | 17 | private final Drive driveService; 18 | private final String googleDriveRootFolderId; 19 | 20 | 21 | public GoogleDriveClient(Drive driveService, String googleDriveRootFolderId) { 22 | this.driveService = driveService; 23 | this.googleDriveRootFolderId = googleDriveRootFolderId; 24 | } 25 | 26 | 27 | public boolean upload(java.io.File fileToUpload) { 28 | 29 | // create a file 30 | /* 31 | Service accounts also have their own Google Drive space. If we would create a new folder or file, it would be created in that space. 32 | But the problem is that the drive space won't be accessible from a GUI since the "real" user (who created the service account) doesn't have access 33 | to the drive space of the service account and there is no way to login with a service account to access the GUI. 34 | So the only way to see the files is through API calls. 35 | */ 36 | 37 | log.info("Google Drive: uploading file '{}' ...", fileToUpload.getName()); 38 | if (!(fileToUpload.exists() && fileToUpload.isFile())) { 39 | log.error("Google Drive: could not find {} in project root directory", fileToUpload.getName()); 40 | return false; 41 | } 42 | 43 | FileContent notionExportFileContent = new FileContent("application/zip", fileToUpload); 44 | File fileMetadata = new File(); 45 | fileMetadata.setName(fileToUpload.getName()); 46 | fileMetadata.setParents(Collections.singletonList(googleDriveRootFolderId)); 47 | try { 48 | driveService.files().create(fileMetadata, notionExportFileContent) 49 | .setFields("id, parents") 50 | .execute(); 51 | } catch (IOException e) { 52 | log.warn("Google Drive: IOException ", e); 53 | return false; 54 | } 55 | log.info("Google Drive: successfully uploaded '{}'", fileToUpload.getName()); 56 | return true; 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/com/greydev/notionbackup/cloudstorage/googledrive/GoogleDriveServiceFactory.java: -------------------------------------------------------------------------------- 1 | package com.greydev.notionbackup.cloudstorage.googledrive; 2 | 3 | import java.io.ByteArrayInputStream; 4 | import java.io.IOException; 5 | import java.security.GeneralSecurityException; 6 | import java.util.Collections; 7 | import java.util.Optional; 8 | 9 | import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; 10 | import com.google.api.client.json.jackson2.JacksonFactory; 11 | import com.google.api.services.drive.Drive; 12 | import com.google.auth.http.HttpCredentialsAdapter; 13 | import com.google.auth.oauth2.GoogleCredentials; 14 | 15 | import lombok.extern.slf4j.Slf4j; 16 | 17 | 18 | @Slf4j 19 | public class GoogleDriveServiceFactory { 20 | 21 | private static final String OAUTH_SCOPE_GOOGLE_DRIVE = "https://www.googleapis.com/auth/drive"; 22 | private static final String APPLICATION_NAME = "NOTION-BACKUP"; 23 | 24 | 25 | private GoogleDriveServiceFactory() { 26 | // can't instantiate 27 | } 28 | 29 | 30 | public static Optional create(String googleDriveServiceAccountSecret) { 31 | //Build service account credential 32 | // We need to set a scope? 33 | // https://developers.google.com/identity/protocols/oauth2/scopes 34 | Drive drive = null; 35 | try { 36 | GoogleCredentials googleCredentials = GoogleCredentials 37 | .fromStream(new ByteArrayInputStream(googleDriveServiceAccountSecret.getBytes())) 38 | .createScoped(Collections.singletonList(OAUTH_SCOPE_GOOGLE_DRIVE)); 39 | drive = new Drive 40 | .Builder(GoogleNetHttpTransport.newTrustedTransport(), JacksonFactory.getDefaultInstance(), new HttpCredentialsAdapter(googleCredentials)) 41 | .setApplicationName(APPLICATION_NAME) 42 | .build(); 43 | } catch (IOException | GeneralSecurityException e) { 44 | log.error("Error during google drive build step", e); 45 | } 46 | return Optional.ofNullable(drive); 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/main/java/com/greydev/notionbackup/cloudstorage/nextcloud/NextcloudClient.java: -------------------------------------------------------------------------------- 1 | package com.greydev.notionbackup.cloudstorage.nextcloud; 2 | 3 | import java.io.File; 4 | import java.io.IOException; 5 | import java.net.Authenticator; 6 | import java.net.PasswordAuthentication; 7 | import java.net.URI; 8 | import java.net.http.HttpClient; 9 | import java.net.http.HttpRequest; 10 | import java.net.http.HttpResponse; 11 | import java.time.Duration; 12 | 13 | import com.greydev.notionbackup.cloudstorage.CloudStorageClient; 14 | 15 | import lombok.extern.slf4j.Slf4j; 16 | 17 | 18 | @Slf4j 19 | public class NextcloudClient implements CloudStorageClient { 20 | 21 | private final String email; 22 | private final String password; 23 | private final String webdavUrl; 24 | 25 | 26 | public NextcloudClient(String email, String password, String webdavUrl) { 27 | this.email = email; 28 | this.password = password; 29 | this.webdavUrl = webdavUrl; 30 | 31 | } 32 | 33 | 34 | @Override 35 | public boolean upload(File fileToUpload) { 36 | log.info("Nextcloud: uploading file '{}' ...", fileToUpload.getName()); 37 | 38 | try { 39 | HttpResponse response = uploadFileToNextcloud(fileToUpload); 40 | 41 | if (response.statusCode() == 201) { 42 | log.info("Nextcloud: successfully uploaded '{}'", fileToUpload.getName()); 43 | return true; 44 | } else if (response.statusCode() == 204) { 45 | log.info("Nextcloud: file upload response code is {}). " + 46 | "This probably means a file with the same name already exists and it was overwritten/replaced.", response.statusCode()); 47 | } else if (response.statusCode() == 404) { 48 | log.info("Nextcloud: file upload response code is {}. The path you provided was not found.", response.statusCode()); 49 | } else { 50 | log.info("Nextcloud: Unknown Nextcloud response code: '{}'", response.statusCode()); 51 | } 52 | 53 | } catch (IOException | InterruptedException e) { 54 | log.error("Nextcloud Exception: ", e); 55 | } 56 | 57 | return false; 58 | } 59 | 60 | 61 | private HttpResponse uploadFileToNextcloud(File fileToUpload) throws IOException, InterruptedException { 62 | HttpClient client = HttpClient.newBuilder() 63 | .connectTimeout(Duration.ofSeconds(10)) 64 | .authenticator(new Authenticator() { 65 | @Override 66 | protected PasswordAuthentication getPasswordAuthentication() { 67 | return new PasswordAuthentication(email, password.toCharArray()); 68 | } 69 | }) 70 | .version(HttpClient.Version.HTTP_1_1) 71 | .build(); 72 | 73 | HttpRequest request = HttpRequest.newBuilder() 74 | .uri(URI.create(webdavUrl.endsWith("/") ? (webdavUrl + fileToUpload.getName()) : webdavUrl)) 75 | .PUT(HttpRequest.BodyPublishers.ofFile(fileToUpload.toPath())) 76 | .version(HttpClient.Version.HTTP_1_1) 77 | .build(); 78 | 79 | // Return code is 201 when the file was successfully uploaded 80 | // Return code is 204 when the file already exists (file will be overwritten/replaced) 81 | return client.send(request, HttpResponse.BodyHandlers.ofString()); 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /src/main/java/com/greydev/notionbackup/cloudstorage/pcloud/PCloudApiClientFactory.java: -------------------------------------------------------------------------------- 1 | package com.greydev.notionbackup.cloudstorage.pcloud; 2 | 3 | import com.pcloud.sdk.ApiClient; 4 | import com.pcloud.sdk.Authenticators; 5 | import com.pcloud.sdk.PCloudSdk; 6 | import lombok.extern.slf4j.Slf4j; 7 | 8 | import java.util.Optional; 9 | 10 | @Slf4j 11 | public class PCloudApiClientFactory { 12 | 13 | public static Optional create(String accessToken, String apiHost) { 14 | ApiClient client = null; 15 | try { 16 | client = PCloudSdk.newClientBuilder() 17 | .authenticator(Authenticators.newOauthAuthenticator(() -> accessToken)) 18 | .apiHost(apiHost) 19 | .create(); 20 | } catch (Exception e) { 21 | log.warn("An exception occurred: ", e); 22 | } 23 | return Optional.ofNullable(client); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/greydev/notionbackup/cloudstorage/pcloud/PCloudClient.java: -------------------------------------------------------------------------------- 1 | package com.greydev.notionbackup.cloudstorage.pcloud; 2 | 3 | import com.greydev.notionbackup.cloudstorage.CloudStorageClient; 4 | import com.pcloud.sdk.*; 5 | import lombok.extern.slf4j.Slf4j; 6 | 7 | import java.io.File; 8 | import java.io.IOException; 9 | 10 | @Slf4j 11 | public class PCloudClient implements CloudStorageClient { 12 | 13 | private final ApiClient apiClient; 14 | private final long folderId; 15 | 16 | public PCloudClient(ApiClient apiClient, long folderId) { 17 | this.apiClient = apiClient; 18 | this.folderId = folderId; 19 | } 20 | 21 | @Override 22 | public boolean upload(File fileToUpload) { 23 | try { 24 | apiClient.createFile(folderId, fileToUpload.getName(), DataSource.create(fileToUpload)).execute(); 25 | log.info("pCloud: successfully uploaded {}", fileToUpload.getName()); 26 | return true; 27 | } catch (IOException | ApiError e) { 28 | log.warn("pCloud: exception during upload of file '{}'", fileToUpload.getName(), e); 29 | } 30 | return false; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/greydev/notionbackup/model/Result.java: -------------------------------------------------------------------------------- 1 | package com.greydev.notionbackup.model; 2 | 3 | import org.apache.commons.lang3.StringUtils; 4 | 5 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 6 | 7 | import lombok.Data; 8 | 9 | 10 | @JsonIgnoreProperties(ignoreUnknown = true) 11 | @Data 12 | public class Result { 13 | 14 | private String state; 15 | 16 | private Status status; 17 | 18 | private String error; // error description 19 | 20 | public boolean isSuccess() { 21 | return StringUtils.equalsIgnoreCase(this.state, "success"); 22 | } 23 | 24 | public boolean isFailure() { 25 | return StringUtils.equalsIgnoreCase(this.state, "failure"); 26 | } 27 | 28 | } 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/main/java/com/greydev/notionbackup/model/Results.java: -------------------------------------------------------------------------------- 1 | package com.greydev.notionbackup.model; 2 | 3 | import java.util.List; 4 | 5 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 6 | 7 | import lombok.Data; 8 | 9 | 10 | @JsonIgnoreProperties(ignoreUnknown = true) 11 | @Data 12 | public class Results { 13 | 14 | private List results; 15 | 16 | } 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/main/java/com/greydev/notionbackup/model/Status.java: -------------------------------------------------------------------------------- 1 | package com.greydev.notionbackup.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | 6 | import lombok.Data; 7 | 8 | 9 | @JsonIgnoreProperties(ignoreUnknown = true) 10 | @Data 11 | public class Status { 12 | 13 | private String type; 14 | 15 | private Integer pagesExported; 16 | 17 | @JsonProperty("exportURL") 18 | private String exportUrl; 19 | 20 | } -------------------------------------------------------------------------------- /src/test/java/com/greydev/notionbackup/cloudstorage/dropbox/DropboxClientTest.java: -------------------------------------------------------------------------------- 1 | package com.greydev.notionbackup.cloudstorage.dropbox; 2 | 3 | import java.io.File; 4 | import java.io.FileInputStream; 5 | import java.io.IOException; 6 | import java.util.List; 7 | 8 | import org.junit.jupiter.api.Test; 9 | import org.junit.jupiter.api.extension.ExtendWith; 10 | import org.mockito.Answers; 11 | import org.mockito.Mock; 12 | import org.mockito.junit.jupiter.MockitoExtension; 13 | 14 | import com.dropbox.core.DbxException; 15 | import com.dropbox.core.v2.DbxClientV2; 16 | import com.dropbox.core.v2.files.ListFolderResult; 17 | import com.dropbox.core.v2.files.Metadata; 18 | 19 | import lombok.extern.slf4j.Slf4j; 20 | 21 | import static org.junit.jupiter.api.Assertions.assertFalse; 22 | import static org.junit.jupiter.api.Assertions.assertTrue; 23 | import static org.mockito.ArgumentMatchers.any; 24 | import static org.mockito.ArgumentMatchers.anyString; 25 | import static org.mockito.Mockito.*; 26 | 27 | 28 | @Slf4j 29 | @ExtendWith(MockitoExtension.class) 30 | public class DropboxClientTest { 31 | // TODO use beforeEach or a similar construct to aggregate common logic 32 | 33 | @Mock(answer = Answers.RETURNS_DEEP_STUBS) 34 | private DbxClientV2 dropboxService; 35 | 36 | 37 | @Test 38 | public void testUpload_success() throws DbxException, IOException { 39 | // given 40 | DropboxClient testee = spy(new DropboxClient(dropboxService)); 41 | 42 | // Mocking a file is not a good idea 43 | File testFileToUpload = new File("src/test/resources/testFileToUpload.txt"); 44 | if (!testFileToUpload.exists()) { 45 | System.out.println("Test file does not exist..."); 46 | } 47 | // TODO what to return here? 48 | when(dropboxService.files().uploadBuilder(anyString()).uploadAndFinish(any())).thenReturn(null); 49 | doReturn(true).when(testee).doesFileExist(anyString()); 50 | 51 | // when 52 | boolean result = testee.upload(testFileToUpload); 53 | 54 | // then 55 | // TODO verify each method call in call chain? 56 | verify(dropboxService.files().uploadBuilder("/testFileToUpload.txt")).uploadAndFinish(any(FileInputStream.class)); 57 | verify(testee).doesFileExist("testFileToUpload.txt"); 58 | assertTrue(result); 59 | } 60 | 61 | 62 | @Test 63 | public void testUpload_givenFileToUploadDoesNotExist() throws DbxException { 64 | // given 65 | DropboxClient testee = spy(new DropboxClient(dropboxService)); 66 | File nonExistingFile = new File("thisFileDoesNotExist.txt"); 67 | 68 | // when 69 | boolean result = testee.upload(nonExistingFile); 70 | 71 | // then 72 | verifyNoInteractions(dropboxService); 73 | verify(testee, never()).doesFileExist(anyString()); 74 | assertFalse(result); 75 | } 76 | 77 | 78 | @Test 79 | public void testUpload_failureBecauseDbxExceptionDuringUpload() throws DbxException, IOException { 80 | // given 81 | DropboxClient testee = spy(new DropboxClient(dropboxService)); 82 | 83 | // Mocking a file is not a good idea 84 | File testFileToUpload = new File("src/test/resources/testFileToUpload.txt"); 85 | if (!testFileToUpload.exists()) { 86 | System.out.println("Test file does not exist..."); 87 | } 88 | when(dropboxService.files().uploadBuilder(anyString()).uploadAndFinish(any())).thenThrow(DbxException.class); 89 | 90 | // when 91 | boolean result = testee.upload(testFileToUpload); 92 | 93 | // then 94 | verify(dropboxService.files().uploadBuilder("/testFileToUpload.txt")).uploadAndFinish(any(FileInputStream.class)); 95 | verify(testee, never()).doesFileExist(anyString()); 96 | assertFalse(result); 97 | } 98 | 99 | 100 | @Test 101 | public void testUpload_failureBecauseFileNotFoundAfterUpload() throws DbxException, IOException { 102 | // given 103 | DropboxClient testee = spy(new DropboxClient(dropboxService)); 104 | 105 | // Mocking a file is not a good idea 106 | File testFileToUpload = new File("src/test/resources/testFileToUpload.txt"); 107 | if (!testFileToUpload.exists()) { 108 | System.out.println("Test file does not exist..."); 109 | } 110 | when(dropboxService.files().uploadBuilder(anyString()).uploadAndFinish(any())).thenReturn(null); 111 | doReturn(false).when(testee).doesFileExist(anyString()); 112 | 113 | // when 114 | boolean result = testee.upload(testFileToUpload); 115 | 116 | // then 117 | verify(dropboxService.files().uploadBuilder("/testFileToUpload.txt")).uploadAndFinish(any(FileInputStream.class)); 118 | verify(testee).doesFileExist("testFileToUpload.txt"); 119 | assertFalse(result); 120 | } 121 | 122 | 123 | @Test 124 | public void testDoesFileExist_true() throws DbxException { 125 | // given 126 | DropboxClient testee = new DropboxClient(dropboxService); 127 | 128 | Metadata m1 = new Metadata("folder1"); 129 | Metadata m2 = new Metadata("folder2"); 130 | Metadata m3 = new Metadata("testFileToUpload.txt"); 131 | List metadataList = List.of(m1, m2, m3); 132 | ListFolderResult listFolderResult = new ListFolderResult(metadataList, "3", true); 133 | when(dropboxService.files().listFolder(anyString())).thenReturn(listFolderResult); 134 | clearInvocations(dropboxService); 135 | 136 | // when 137 | boolean result = testee.doesFileExist("testFileToUpload.txt"); 138 | 139 | // then 140 | verify(dropboxService.files()).listFolder(""); 141 | assertTrue(result); 142 | } 143 | 144 | 145 | @Test 146 | public void testDoesFileExist_false() throws DbxException { 147 | // given 148 | DropboxClient testee = new DropboxClient(dropboxService); 149 | 150 | Metadata m1 = new Metadata("folder1"); 151 | Metadata m2 = new Metadata("folder2"); 152 | List metadataList = List.of(m1, m2); 153 | ListFolderResult listFolderResult = new ListFolderResult(metadataList, "2", true); 154 | 155 | when(dropboxService.files().listFolder(anyString())).thenReturn(listFolderResult); 156 | clearInvocations(dropboxService); // TODO wut? 157 | 158 | // when 159 | boolean result = testee.doesFileExist("testFileToUpload.txt"); 160 | 161 | // then 162 | verify(dropboxService.files()).listFolder(""); 163 | assertFalse(result); 164 | } 165 | 166 | } 167 | -------------------------------------------------------------------------------- /src/test/java/com/greydev/notionbackup/cloudstorage/googledrive/GoogleDriveClientTest.java: -------------------------------------------------------------------------------- 1 | package com.greydev.notionbackup.cloudstorage.googledrive; 2 | 3 | import java.io.File; 4 | import java.io.IOException; 5 | import java.util.Collections; 6 | 7 | import org.junit.jupiter.api.Test; 8 | import org.junit.jupiter.api.extension.ExtendWith; 9 | import org.mockito.Answers; 10 | import org.mockito.Mock; 11 | import org.mockito.junit.jupiter.MockitoExtension; 12 | 13 | import com.google.api.client.http.FileContent; 14 | import com.google.api.services.drive.Drive; 15 | 16 | import static org.junit.jupiter.api.Assertions.assertFalse; 17 | import static org.junit.jupiter.api.Assertions.assertTrue; 18 | import static org.mockito.ArgumentMatchers.any; 19 | import static org.mockito.ArgumentMatchers.anyString; 20 | import static org.mockito.ArgumentMatchers.eq; 21 | import static org.mockito.Mockito.clearInvocations; 22 | import static org.mockito.Mockito.verify; 23 | import static org.mockito.Mockito.verifyNoInteractions; 24 | import static org.mockito.Mockito.when; 25 | 26 | 27 | @ExtendWith(MockitoExtension.class) 28 | class GoogleDriveClientTest { 29 | 30 | @Mock(answer = Answers.RETURNS_DEEP_STUBS) 31 | private Drive googleDriveService; 32 | 33 | 34 | @Test 35 | public void testUpload() throws IOException { 36 | // given 37 | File fileToUpload = new File("src/test/resources/testFileToUpload.txt"); 38 | String googleDriveRootFolderId = "parentFolderId"; 39 | GoogleDriveClient googleService = new GoogleDriveClient(googleDriveService, googleDriveRootFolderId); 40 | 41 | com.google.api.services.drive.model.File fileMetadata = new com.google.api.services.drive.model.File(); 42 | fileMetadata.setName("testFileToUpload.txt"); 43 | fileMetadata.setParents(Collections.singletonList("parentFolderId")); 44 | 45 | FileContent notionExportFileContent = new FileContent("application/zip", fileToUpload); 46 | 47 | when(googleDriveService.files().create(any(), any()).setFields(anyString()).execute()).thenReturn(null); 48 | clearInvocations(googleDriveService); 49 | 50 | // when 51 | boolean result = googleService.upload(fileToUpload); 52 | 53 | // then 54 | assertTrue(result); 55 | verify(googleDriveService).files(); 56 | // eq(notionExportFileContent) does not work I assume because FileContent doesn't override the equals method? 57 | // com.google.api.client.http.FileContent@66908383 is not the same as com.google.api.client.http.FileContent@736ac09a 58 | // but eq() works for com.google.api.services.drive.model.File -> the toString {"name" = "testFileToUpload.txt", "parents" = [parentFolderId]} 59 | verify(googleDriveService.files()).create(eq(fileMetadata), any(FileContent.class)); 60 | verify(googleDriveService.files().create(eq(fileMetadata), any(FileContent.class))).setFields("id, parents"); 61 | verify(googleDriveService.files().create(eq(fileMetadata), any(FileContent.class)).setFields("id, parents")).execute(); 62 | } 63 | 64 | 65 | @Test 66 | public void testUpload_IOException() throws IOException { 67 | // given 68 | File fileToUpload = new File("src/test/resources/testFileToUpload.txt"); 69 | String googleDriveRootFolderId = "parentFolderId"; 70 | GoogleDriveClient googleService = new GoogleDriveClient(googleDriveService, googleDriveRootFolderId); 71 | 72 | com.google.api.services.drive.model.File fileMetadata = new com.google.api.services.drive.model.File(); 73 | fileMetadata.setName("testFileToUpload.txt"); 74 | fileMetadata.setParents(Collections.singletonList("parentFolderId")); 75 | 76 | when(googleDriveService.files().create(any(), any())).thenThrow(IOException.class); 77 | clearInvocations(googleDriveService); 78 | 79 | // when 80 | boolean result = googleService.upload(fileToUpload); 81 | 82 | // then 83 | assertFalse(result); 84 | verify(googleDriveService).files(); 85 | verify(googleDriveService.files()).create(eq(fileMetadata), any(FileContent.class)); 86 | } 87 | 88 | 89 | @Test 90 | public void testUpload_invalidFile() { 91 | // given 92 | File fileToUpload = new File("thisFileDoesNotExist.txt"); 93 | String googleDriveRootFolderId = "parentFolderId"; 94 | GoogleDriveClient googleService = new GoogleDriveClient(googleDriveService, googleDriveRootFolderId); 95 | 96 | // when 97 | boolean result = googleService.upload(fileToUpload); 98 | 99 | // then 100 | assertFalse(result); 101 | verifyNoInteractions(googleDriveService); 102 | } 103 | 104 | } -------------------------------------------------------------------------------- /src/test/resources/testFileToUpload.txt: -------------------------------------------------------------------------------- 1 | This is a test file -------------------------------------------------------------------------------- /testing-with-curl/README.md: -------------------------------------------------------------------------------- 1 | # Testing the Notion API with cURL 2 | 3 | ```bash 4 | # TODO - example with curl on how trigger an export 5 | 6 | # This will return a long list of notifications, also some from the past 7 | # Search for 'export-completed' to find the export URL 8 | # Make sure the timestamp is the one you are looking for 9 | curl -X POST https://www.notion.so/api/v3/getNotificationLogV2 \ 10 | -H "Content-Type: application/json" \ 11 | -H "Cookie: token_v2=$NOTION_TOKEN_V2" \ 12 | -d '{ 13 | "spaceId": "'"$NOTION_SPACE_ID"'", 14 | "size": 20, 15 | "type": "unread_and_read", 16 | "variant": "no_grouping" 17 | }' 18 | 19 | # TODO - example with curl on how to download the file, once you have the export URL 20 | 21 | 22 | ``` --------------------------------------------------------------------------------