├── .gitignore ├── .vscode └── tasks.json ├── LICENSE ├── README.md ├── apiRequest.go ├── backup.go ├── export.go ├── extractZip.go ├── getPages.go ├── main.go ├── random.go ├── settings.go └── shell.go /.gitignore: -------------------------------------------------------------------------------- 1 | Notion-Backup 2 | settings.json 3 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Build and run", 6 | "type": "shell", 7 | "command": "go run .", 8 | "group": { 9 | "kind": "build", 10 | "isDefault": true 11 | } 12 | } 13 | ] 14 | } 15 | 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Mr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notion-Backup 2 | Backup all of your notion notes, and optionally upload them to a git repo. 3 | 4 | ## Usage 5 | 6 | 0. Make sure you have git installed and it's in your $PATH variable. 7 | 1. Create a folder where you want to export your notion notes to and optionally make it a git repo. 8 | 2. Run Notion-Backup executable, if you don't want to backup to a git repo append `nogit` to the end of the command (eg. `./Notion-Backup nogit`). 9 | 10 | Run Notion-Backup from your cli (e.g., on windows open cmd and execute Notion-Backup.exe from there, if on linux do the same but in your shell). 11 | You can probably skip this if you have your Git login saved, but if you encounter any errors, try running from your shell. 12 | 3. (Happens only first time) Enter your API Token (look below on how to get it). 13 | 4. (Happens only first time) Enter the path to the folder you created in step 1. 14 | 5. Wait for Notion-Backup to download your notes. 15 | 6. If asked for authentication: 16 | - **Windows** 17 | 18 | On windows a prompt will open allowing you to login to Git through the web browser or by using a personal access token. 19 | 20 | - **Linux** 21 | 22 | On Linux you will be asked for a username and password in the command line. If you have 2FA enabled, use a personal access token 23 | as your password with the `repo` privilege set. 24 | 25 | Optionally you can save your login credentials for Git so you don't have to authenticate everytime: 26 | ``` 27 | # More info: https://stackoverflow.com/a/35942890/11025032 28 | git config --global credential.helper store 29 | git pull 30 | ``` 31 | 7. Done.. 32 | 33 | ## Getting your notion API Key 34 | 35 | 1. Go to `notion.so` in your web browser. 36 | 2. Open `Inspect Element` (Ctrl+Shift+I) and go to the `Network tab`. 37 | 3. Refresh the page (whilst logged in) so network requests show up. 38 | 4. Click on the `first request` (most of the other requests will also work) and in the new menu click on `Cookies`. 39 | 5. Under `Request Cookies` find `token_v2` and copy its value (eg. `token_v2: "myTokenHere"` in this case, your token is: `myTokenHere`). 40 | 6. If you don't see `token_v2`, click on a different request and then repeat from step 4. 41 | 7. Done.. copy your API Token into Notion-Backup. 42 | -------------------------------------------------------------------------------- /apiRequest.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | func requestData(apiFile string, reqBody []byte) []byte { 11 | // Create new HTTP POST request 12 | req, err := http.NewRequest("POST", "https://www.notion.so/api/v3/"+apiFile, bytes.NewBuffer(reqBody)) 13 | if err != nil { 14 | print("POST request error: ", err) 15 | } 16 | 17 | // Add HTTP headers 18 | req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:79.0) Gecko/20100101 Firefox/79.0") 19 | req.Header.Set("Content-Type", "application/json") 20 | req.Header.Set("Accept-Language", "en-US,en;q=0.5") 21 | 22 | // Add api token in cookies 23 | req.AddCookie(&http.Cookie{Name: "token_v2", Value: APITokenV2}) 24 | 25 | // Create client 26 | client := &http.Client{Timeout: time.Second * 10} 27 | 28 | // Execute request 29 | resp, err := client.Do(req) 30 | 31 | // Read response from req 32 | body, err := ioutil.ReadAll(resp.Body) 33 | if err != nil { 34 | print("Error reading response body: ", err) 35 | } 36 | 37 | return body 38 | } 39 | -------------------------------------------------------------------------------- /backup.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | func setupRepo() { 11 | // Check if folder exists 12 | _, err := os.Stat(GitRepoFolder) 13 | if os.IsNotExist(err) { 14 | fmt.Println("Git repo folder doesn't exist. Edit your settings: " + getSettingsFile()) 15 | os.Exit(1) 16 | } 17 | 18 | // Check if folder is a git repository 19 | _, err = runCommand("git status", GitRepoFolder, false) 20 | if err != nil { 21 | fmt.Println("Folder is not initialized with git.") 22 | os.Exit(1) 23 | } 24 | } 25 | 26 | func checkForGit() bool { 27 | _, err := runCommand("git --version", "", false) 28 | if err != nil { 29 | return false 30 | } 31 | 32 | return true 33 | } 34 | 35 | func commitBackup() { 36 | fmt.Println("Committing and pushing updated notes.") 37 | 38 | commitMsg := "Backup " + time.Now().Format("02.01.06 - 15:04:05") + "" 39 | commands := [3]string{"git add *", "git commit -a -m \"" + commitMsg + "\"", "git push"} 40 | 41 | for _, command := range commands { 42 | out, err := runCommand(command, GitRepoFolder, true) 43 | if err != nil { 44 | outStr := strings.ToLower(string(out)) 45 | 46 | if strings.Contains(outStr, "nothing to commit") { 47 | fmt.Println("No updated notes to commit.") 48 | } else if strings.Contains(outStr, "authentication failed") { 49 | fmt.Println("Authentication failed! Login and save credentials for git in the command line or follow authentication prompt.") 50 | os.Exit(1) 51 | } else { 52 | fmt.Println(fmt.Sprint(err) + ": " + string(out)) 53 | os.Exit(1) 54 | } 55 | } 56 | } 57 | 58 | fmt.Println("Pushed Commit '" + commitMsg + "'") 59 | } 60 | -------------------------------------------------------------------------------- /export.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | "time" 11 | ) 12 | 13 | type exportRequest struct { 14 | Task *exportTask `json:"task"` 15 | } 16 | 17 | type exportTask struct { 18 | EventName string `json:"eventName"` 19 | Request *exportTaskRequest `json:"request"` 20 | } 21 | 22 | type exportTaskRequest struct { 23 | BlockID string `json:"blockId"` 24 | Recursive bool `json:"recursive"` 25 | ExportOptions *exportTaskRequestOptions `json:"exportOptions"` 26 | } 27 | 28 | type exportTaskRequestOptions struct { 29 | ExportType string `json:"exportType"` 30 | TimeZone string `json:"timeZone"` 31 | Locale string `json:"locale"` 32 | } 33 | 34 | type exportRequestResponse struct { 35 | TaskID string `json:"taskId"` 36 | } 37 | 38 | type getTasks struct { 39 | TaskIDs []string `json:"taskIds"` 40 | } 41 | 42 | type getTasksResponse struct { 43 | Results []*getTasksResponseResults `json:"results"` 44 | } 45 | 46 | type getTasksResponseResults struct { 47 | Status *getTasksResponseStatus `json:"status"` 48 | } 49 | 50 | type getTasksResponseStatus struct { 51 | Type string `json:"type"` 52 | ExportURL string `json:"exportURL"` 53 | } 54 | 55 | // Return all file paths of exported zips 56 | func getBackup() []string { 57 | return enqueueTask() 58 | } 59 | 60 | func enqueueTask() []string { 61 | var exportedFiles []string 62 | blockIDS := getPages() 63 | 64 | for _, val := range blockIDS { 65 | fmt.Print("Working on: ", val) 66 | 67 | t := &exportRequest{ 68 | Task: &exportTask{ 69 | EventName: "exportBlock", 70 | Request: &exportTaskRequest{ 71 | BlockID: val, 72 | Recursive: true, 73 | ExportOptions: &exportTaskRequestOptions{ 74 | ExportType: "markdown", 75 | TimeZone: "Europe/London", 76 | Locale: "en", 77 | }, 78 | }, 79 | }, 80 | } 81 | 82 | // Serialize json request 83 | reqBody, err := json.Marshal(t) 84 | if err != nil { 85 | fmt.Println("Error serializing json: ", err) 86 | } 87 | 88 | // Send request 89 | reply := requestData("enqueueTask", reqBody) 90 | 91 | // Deserialize json response 92 | end := exportRequestResponse{} 93 | json.Unmarshal(reply, &end) 94 | 95 | // Download export and add its full path to 'exportedFiles' 96 | exportedFiles = append(exportedFiles, downloadExport(end.TaskID)) 97 | 98 | fmt.Println(" ... Completed!") 99 | } 100 | 101 | return exportedFiles 102 | } 103 | 104 | func downloadExport(taskID string) string { 105 | var exportURL string 106 | 107 | // Get exportURL 108 | for { 109 | // Give some time for export file to be created 110 | time.Sleep(1000 * time.Millisecond) 111 | 112 | t := getTasks{ 113 | TaskIDs: []string{taskID}, 114 | } 115 | 116 | // Serialize json request 117 | reqBody, err := json.Marshal(t) 118 | if err != nil { 119 | fmt.Println("Error serializing json: ", err) 120 | } 121 | 122 | // Request task info 123 | reply := requestData("getTasks", reqBody) 124 | 125 | // Deserialize json response 126 | end := getTasksResponse{} 127 | json.Unmarshal(reply, &end) 128 | 129 | // If status is complete, break from loop and set exportURL var 130 | if end.Results != nil { 131 | s := end.Results[0].Status 132 | if s != nil && s.Type == "complete" { 133 | exportURL = s.ExportURL 134 | break 135 | } 136 | } 137 | 138 | // Wait an extra second and tell user 139 | fmt.Print(" ... Retry in 2s") 140 | time.Sleep(1000 * time.Millisecond) 141 | } 142 | 143 | // Download file to 'export.zip' 144 | var filename string = "export" + randomStr(10) + ".zip" 145 | 146 | // Get the response bytes from the url 147 | resp, err := http.Get(exportURL) 148 | if err != nil { 149 | fmt.Println("Error downloading file: ", err) 150 | } 151 | defer resp.Body.Close() 152 | 153 | // Create an empty file 154 | file, err := os.Create(filename) 155 | if err != nil { 156 | fmt.Println("Error creating file: ", err) 157 | } 158 | defer file.Close() 159 | 160 | // Write the bytes to the file 161 | _, err = io.Copy(file, resp.Body) 162 | if err != nil { 163 | fmt.Println("Error writing to file: ", err) 164 | } 165 | 166 | // Return downloaded files full path 167 | path, err := filepath.Abs(filename) 168 | if err != nil { 169 | fmt.Println("Error getting exported zips absolute path ", err) 170 | } 171 | 172 | return path 173 | } 174 | -------------------------------------------------------------------------------- /extractZip.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "archive/zip" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | // Extract zip 13 | func extract(file string, dest string) error { 14 | fmt.Println("Extracting " + file) 15 | 16 | dest = filepath.Clean(dest) + string(os.PathSeparator) 17 | 18 | r, err := zip.OpenReader(file) 19 | if err != nil { 20 | return err 21 | } 22 | defer func() { 23 | if err := r.Close(); err != nil { 24 | panic(err) 25 | } 26 | }() 27 | 28 | os.MkdirAll(dest, 0766) 29 | 30 | extractAndWriteFile := func(f *zip.File) error { 31 | path := filepath.Join(dest, f.Name) 32 | 33 | if !strings.HasPrefix(path, dest) { 34 | return fmt.Errorf("%s: illegal file path", path) 35 | } 36 | 37 | rc, err := f.Open() 38 | if err != nil { 39 | return err 40 | } 41 | defer func() { 42 | if err := rc.Close(); err != nil { 43 | panic(err) 44 | } 45 | }() 46 | 47 | if f.FileInfo().IsDir() { 48 | os.MkdirAll(path, f.Mode()) 49 | } else { 50 | os.MkdirAll(filepath.Dir(path), f.Mode()) 51 | 52 | // Set perms for file 53 | err := os.Chmod(filepath.Dir(path), 0766) 54 | if err != nil { 55 | panic(err) 56 | } 57 | 58 | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) 59 | if err != nil { 60 | return err 61 | } 62 | defer func() { 63 | if err := f.Close(); err != nil { 64 | panic(err) 65 | } 66 | }() 67 | 68 | // Copy files contents 69 | _, err = io.Copy(f, rc) 70 | if err != nil { 71 | return err 72 | } 73 | } 74 | return nil 75 | } 76 | 77 | for _, f := range r.File { 78 | err := extractAndWriteFile(f) 79 | if err != nil { 80 | return err 81 | } 82 | } 83 | 84 | return nil 85 | } 86 | -------------------------------------------------------------------------------- /getPages.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | type sharedPagesRequest struct { 8 | IncludeDeleted bool `json:"includeDeleted"` 9 | } 10 | 11 | type sharedPagesResponse struct { 12 | Pages []struct { 13 | ID string `json:"id"` 14 | SpaceID string `json:"spaceId"` 15 | } `json:"pages"` 16 | } 17 | 18 | func getPages() []string { 19 | t := sharedPagesRequest{ 20 | IncludeDeleted: false, 21 | } 22 | 23 | // Serialize json request 24 | reqBody, err := json.Marshal(t) 25 | if err != nil { 26 | print("error") 27 | } 28 | 29 | // Send request 30 | reply := requestData("getUserSharedPages", reqBody) 31 | 32 | // Deserialize json response 33 | end := sharedPagesResponse{} 34 | json.Unmarshal(reply, &end) 35 | 36 | var pages []string 37 | for i := range end.Pages { 38 | // Add page block ids to pages[] 39 | pages = append(pages, end.Pages[i].ID) 40 | } 41 | 42 | return pages 43 | } 44 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | func main() { 10 | var nogit bool = false 11 | 12 | // Check if any args have been set 13 | if len(os.Args) > 1 { 14 | if os.Args[1] == "nogit" { 15 | nogit = true 16 | fmt.Println("Ok, won't backup to git. Will still move exported files to GitRepoFolder.") 17 | } else { 18 | fmt.Println("The only supported arg is 'nogit'") 19 | os.Exit(1) 20 | } 21 | } 22 | 23 | // Get settings from settings.json 24 | // If they don't exist, create them 25 | err := getSettings() 26 | if err != nil { 27 | fmt.Println("Error getting settings: ", err) 28 | } 29 | 30 | if !nogit { 31 | // Check if git is accessible 32 | if !checkForGit() { 33 | fmt.Println("Can't access git, make sure it is in your $PATH variable.") 34 | os.Exit(1) 35 | } 36 | 37 | // Check if GitRepoFolder exists and is a git repo 38 | setupRepo() 39 | } 40 | 41 | // Download backup & get full path to it 42 | files := getBackup() 43 | 44 | // Foreach export zip, extract to gitrepo folder then delete zip 45 | for _, file := range files { 46 | // Extract downloaded backup 47 | err := extract(file, filepath.Join(GitRepoFolder, "notes")) 48 | if err != nil { 49 | print(err.Error) 50 | } 51 | 52 | // Delete zip 53 | err = os.Remove(file) 54 | if err != nil { 55 | print(err.Error) 56 | } 57 | } 58 | 59 | if !nogit { 60 | // Commit and push changes 61 | commitBackup() 62 | } 63 | 64 | fmt.Println("Done backing up.") 65 | } 66 | -------------------------------------------------------------------------------- /random.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | ) 7 | 8 | var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") 9 | 10 | // Generate random string 11 | // (int)n - How long to make string 12 | func randomStr(n int) string { 13 | rand.Seed(time.Now().UnixNano()) 14 | 15 | b := make([]rune, n) 16 | for i := range b { 17 | b[i] = letters[rand.Intn(len(letters))] 18 | } 19 | return string(b) 20 | } 21 | -------------------------------------------------------------------------------- /settings.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | ) 10 | 11 | type settings struct { 12 | APITokenV2 string `json:"API_TOKEN_V2"` 13 | GitRepoFolder string `json:"GIT_REPO_FOLDER"` 14 | } 15 | 16 | // APITokenV2 : Users token for notion api 17 | var APITokenV2 string 18 | 19 | // GitRepoFolder : Where should program find git repository 20 | var GitRepoFolder string 21 | 22 | func getSettings() error { 23 | // Get settings file, create if doesn't exist 24 | file := getSettingsFile() 25 | 26 | // Read everything in settings file 27 | s, err := readSettings(file) 28 | if err != nil { 29 | return errors.New("Error getting settings: " + err.Error()) 30 | } 31 | 32 | // Deserialize json settings 33 | end := settings{} 34 | json.Unmarshal(s, &end) 35 | 36 | var didAppnd int = 0 37 | 38 | // Check if each setting is in json file, if they aren't ask for them and write them 39 | 40 | if end.APITokenV2 != "" { 41 | APITokenV2 = end.APITokenV2 42 | } else { 43 | APITokenV2Input := askForSetting("API Token: ") 44 | end.APITokenV2 = APITokenV2Input 45 | 46 | didAppnd++ 47 | } 48 | 49 | if end.GitRepoFolder != "" { 50 | GitRepoFolder = end.GitRepoFolder 51 | } else { 52 | var GitRepoFolderInput string 53 | 54 | // Get git repo from user & keep asking until user gives a folder that exists 55 | for { 56 | GitRepoFolderInput = askForSetting("Git repo folder: ") 57 | 58 | _, err := os.Stat(GitRepoFolderInput) 59 | if os.IsNotExist(err) { 60 | fmt.Println("That folder does not exist. Make sure you are using the correct path!") 61 | GitRepoFolderInput = askForSetting("Git repo folder: ") 62 | } else { 63 | break 64 | } 65 | } 66 | 67 | end.GitRepoFolder = GitRepoFolderInput 68 | 69 | didAppnd++ 70 | } 71 | 72 | // If did append settings, write all settings back to file 73 | if didAppnd > 0 { 74 | err := writeSettings(file, end) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | // Re-run getSettings to set global setting vars 80 | getSettings() 81 | } 82 | 83 | return nil 84 | } 85 | 86 | func writeSettings(file string, s settings) error { 87 | // Serialize json request 88 | settingsJSON, err := json.Marshal(s) 89 | if err != nil { 90 | return errors.New("Error writing settings: " + err.Error()) 91 | } 92 | 93 | // Write settings 94 | ioutil.WriteFile(file, settingsJSON, 0644) 95 | 96 | return nil 97 | } 98 | 99 | func askForSetting(question string) string { 100 | fmt.Println(question) 101 | var response string 102 | fmt.Scanln(&response) 103 | 104 | return response 105 | } 106 | 107 | func readSettings(file string) ([]byte, error) { 108 | // Read settings file 109 | data, err := ioutil.ReadFile(file) 110 | if err != nil { 111 | return []byte(""), err 112 | } 113 | 114 | return data, nil 115 | } 116 | 117 | func getSettingsFile() string { 118 | var settingsFile = "settings.json" 119 | 120 | // Create settingsFile if it doesn't already exist 121 | if _, err := os.Stat(settingsFile); os.IsNotExist(err) { 122 | fmt.Println("Settings file not found, creating it.") 123 | os.Create(settingsFile) 124 | } else { 125 | fmt.Println("Found settings file.") 126 | } 127 | 128 | return settingsFile 129 | } 130 | -------------------------------------------------------------------------------- /shell.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os/exec" 5 | "runtime" 6 | ) 7 | 8 | func runCommand(command string, dir string, stdin bool) (string, error) { 9 | shell := "/bin/sh" 10 | prefix := "-" 11 | 12 | // If on windows change shell to powershell 13 | if runtime.GOOS == "windows" { 14 | shell = "powershell" 15 | prefix = "/" 16 | } 17 | 18 | cmd := exec.Command(shell, prefix+"c", command) 19 | cmd.Dir = dir 20 | 21 | if stdin { 22 | cmd.StdinPipe() 23 | } 24 | 25 | out, err := cmd.CombinedOutput() 26 | 27 | return string(out), err 28 | } 29 | --------------------------------------------------------------------------------