├── Set.js ├── LICENSE ├── License.js ├── Library.js ├── Examples.js ├── README.md ├── Remarkable.js └── Synchronizer.js /Set.js: -------------------------------------------------------------------------------- 1 | // https://www.geeksforgeeks.org/sets-in-javascript/ 2 | 3 | Set.prototype.difference = function(otherSet) 4 | { 5 | // creating new set to store difference 6 | var differenceSet = new Set(); 7 | 8 | // iterate over the values 9 | for(var elem of this) 10 | { 11 | // if the value[i] is not present 12 | // in otherSet add to the differenceSet 13 | if(!otherSet.has(elem)) 14 | differenceSet.add(elem); 15 | } 16 | 17 | // returns values of differenceSet 18 | return differenceSet; 19 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Blair Azzopardi 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 | -------------------------------------------------------------------------------- /License.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | MIT License 4 | 5 | Copyright (c) [year] [fullname] 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | 25 | */ -------------------------------------------------------------------------------- /Library.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Synchronizes PDFs from folder in Google Drive with Remarkable cloud. 3 | * 4 | * @rOneTimeCode {string} one time code from https://my.remarkable.com/device/connect/mobile. 5 | * @gdFolderSearchParams {string} google folder id or google drive search sdk string. 6 | * @rRootFolder {string} root folder on Remarkable to sync files to - must already exist. This can be a Remarkable GUID if you know it. 7 | * @syncMode {string} either "update" or "mirror". Mirroring removes files on device if they no longer exist in Google Drive. 8 | * @gdSkipFolders {array} list of names of Google Drive folders to skip syncing. 9 | */ 10 | function syncGoogleDriveWithRemarkableCloud(rOneTimeCode, gdFolderSearchParams, rRootFolder, syncMode="update", gdSkipFolders=[]) { 11 | let sync = new Synchronizer(rOneTimeCode, gdFolderSearchParams, rRootFolder, syncMode, gdSkipFolders); 12 | sync.run(); 13 | } 14 | 15 | 16 | /** 17 | * Resets the device id and token forcing reinitialization of Remarkable Cloud authorization. 18 | */ 19 | function resetRemarkableDevice() { 20 | let userProps = PropertiesService.getUserProperties(); 21 | userProps.deleteProperty(rDeviceTokenKey); 22 | userProps.deleteProperty(rDeviceIdKey); 23 | } 24 | -------------------------------------------------------------------------------- /Examples.js: -------------------------------------------------------------------------------- 1 | function example_run_sync() { 2 | // one time code from https://my.remarkable.com/device/connect/mobile 3 | let rOneTimeCode = "abcdwxyz"; 4 | 5 | // can select google folder by id or using search sdk string 6 | //let gdFolderSearchParams = "0Xxx_0XxxxX1XXX1xXXxxXXxxx0X"; 7 | let gdFolderSearchParams = "title = 'Books' and mimeType = 'application/vnd.google-apps.folder'" 8 | 9 | let sync = new Synchronizer(rOneTimeCode, gdFolderSearchParams, "Google Drive", "update", ["SkipFolder1", "SkipFolder2"]); 10 | sync.run(); 11 | } 12 | 13 | function example_force_sync() { 14 | // one time code from https://my.remarkable.com/device/connect/mobile 15 | let rOneTimeCode = "abcdwxyz"; 16 | 17 | // can select google folder by id or using search sdk string 18 | //let gdFolderSearchParams = "0Xxx_0XxxxX1XXX1xXXxxXXxxx0X"; 19 | let gdFolderSearchParams = "title = 'Books' and mimeType = 'application/vnd.google-apps.folder'" 20 | 21 | const IDS_TO_FORCE = [ 22 | "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx1", 23 | "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx2", 24 | "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx3", 25 | ]; 26 | 27 | const forceFunc = (r, s) => IDS_TO_FORCE.includes(r["ID"]); 28 | // const forceFunc = (r, s) => s["Type"] == "CollectionType"; 29 | // const forceFunc = (r, s) => r["VissibleName"] == "needs_force_push.pdf"; 30 | // const forceFunc = (r, s) => s["Version"] == 1; 31 | 32 | let sync = new Synchronizer(rOneTimeCode, gdFolderSearchParams, "Google Drive", "update", [], forceFunc); 33 | sync.run(); 34 | } 35 | 36 | function example_get_document_info() { 37 | // To re-use a cached device token initialize like this 38 | // let sync = new Synchronizer(rOneTimeCode, gdFolderSearchParams, "Google Drive"); 39 | // let rapi = sync.rApiClient; 40 | // otherwise use one time code from https://my.remarkable.com/device/connect/mobile 41 | let rOneTimeCode = "abcdwxyz"; 42 | let rapi = new RemarkableAPI(null, null, rOneTimeCode); 43 | // example doc with uuid 44 | let docs = rapi.listDocs('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', true); 45 | } 46 | 47 | function example_delete_all_documents() { 48 | // To re-use a cached device token initialize like this 49 | // let sync = new Synchronizer(rOneTimeCode, gdFolderSearchParams, "Google Drive"); 50 | // let rapi = sync.rApiClient; 51 | // otherwise use one time code from https://my.remarkable.com/device/connect/mobile 52 | let rOneTimeCode = "abcdwxyz"; 53 | let rapi = new RemarkableAPI(null, null, rOneTimeCode); 54 | let allDocs = rapi.listDocs(); 55 | // delete all except at top level 56 | let deleteDocs = allDocs.filter((r) => r["Parent"] != ""); 57 | rapi.delete(deleteDocs); 58 | } 59 | 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **I've archived this project since Remarkable changed their backend API. There's an [experimental branch](https://github.com/bsdz/google-drive-remarkable-sync/tree/remarkable_15_api) with partial support for the new API if any one is interested in continuing from there. Alternatively, you might wish to try using [rmapi](https://github.com/juruen/rmapi) in combination with [rclone](https://rclone.org/).** 2 | 3 | # google-drive-remarkable-sync 4 | Apps Script library for synchronising Google Drive folder with Remarkable cloud storage. 5 | 6 | Files kept in Remarkable's cloud storage are automatically synchronised with your Remarkable device. 7 | 8 | Thanks to splitbrain who did the [initial reverse engineering of Remarkable's cloud API](https://github.com/splitbrain/ReMarkableAPI/wiki). 9 | 10 | # Installation 11 | 12 | 1. Go to https://script.google.com and click on "New Project". 13 | Click in menu "File/Rename" and provide suitable name, eg "Sync Google Drive Books to Remarkable". 14 | 15 | 1. Option 1 - Include library from Apps Script 16 | Click in menu "Resources/Libraries" and in "Add a library" paste "1_ftsHelqnCqBXAwFAOv3U-WUUm_n3_nENg7n6BrDDzze7EekBD9vmf-0" without the double quotes. In version drop down choose "Stable" or version "14" Then press "Save" button. 17 | 18 | 2. Option 2 - Copy the code files from this repository into your Apps Script project being careful to rename the *.js files to *.gs files. 19 | 20 | 2. Create a folder at top level in your Remarkable device called "Google Drive". 21 | 22 | 3. In your Code.gs file paste the following code: 23 | 24 | function run_sync() { 25 | // one time code from https://my.remarkable.com/device/connect/mobile 26 | let rOneTimeCode = "abcdwxyz"; 27 | let gdFolderSearchParams = "title = 'Books' and mimeType = 'application/vnd.google-apps.folder'"; 28 | let syncMode = "mirror"; 29 | RemarkableGoogleDriveSyncLib.syncGoogleDriveWithRemarkableCloud(rOneTimeCode, gdFolderSearchParams, "Google Drive", syncMode); 30 | } 31 | 32 | Change the rOneTimeCode to include a one time code obtained from https://my.remarkable.com/device/connect/mobile. Also change the name "Books" in gdFolderSearchParams to the name of your Google Drive folder that contains your relevant PDFs. You can also replace the gdFolderSearchParams with a Google Drive folder ID. syncMode can be either "update" or "mirror" with 33 | mirroring also deleting files from Remarkable device if not found in same location on Google Drive. 34 | 35 | 4. Click the menu "Run/Run function/run_sync"; you will be prompted for Authorization. Click Review Permissions and select your Google account. You will be prompted that the app isn't verified. Click the Advanced hyperlink and choose "Go to (unsafe)". Choose Allow to the permissions shown. 36 | 37 | 5. View the execution log of your project to check everything appears to be working. 38 | 39 | 6. Set up a regular trigger by click in menu "Edit/Current project's triggers". Click "+ Add Trigger" button. Choose run_sync function and select "Time-driver", "Hour timer", "Every hour" and "Notify me daily" then press Save. 40 | 41 | That should be it! 42 | 43 | # Troubleshooting 44 | 45 | 1. If you need to reset your authentication credentials, in your Code.gs paste the following code: 46 | 47 | function reset() { 48 | RemarkableGoogleDriveSyncLib.resetRemarkableDevice(); 49 | } 50 | 51 | Run the above reset function and update the rOneTimeCode with a new obtained from Remarkable's devices page. 52 | 53 | 54 | # Limitations 55 | 56 | * This is a one way sync from Google Drive to Remarkable. 57 | * Files greater than 50MB in size are not transferred. This appears to be a limit set by reMarkable. 58 | -------------------------------------------------------------------------------- /Remarkable.js: -------------------------------------------------------------------------------- 1 | AUTH_HOST = "https://webapp-production-dot-remarkable-production.appspot.com"; 2 | 3 | class RemarkableAPI { 4 | 5 | constructor(deviceId = null, deviceToken = null, oneTimeCode = null) { 6 | // oneTimeCode from ${AUTH_HOST}/device/connect/mobile 7 | if (deviceToken === null && oneTimeCode === null) { 8 | throw "Need at least either device-token or one-time-code"; 9 | } 10 | 11 | if (deviceId === null) { 12 | Logger.log("Creating new Remarkable device id.."); 13 | this.deviceId = Utilities.getUuid(); 14 | } 15 | else { 16 | Logger.log("Using existing Remarkable device id.."); 17 | this.deviceId = deviceId; 18 | } 19 | 20 | if (deviceToken === null) { 21 | Logger.log("Requesting new Remarkable device token from one time code.."); 22 | this.deviceToken = this.constructor._getDeviceToken(this.deviceId, oneTimeCode); 23 | } 24 | else { 25 | Logger.log("Using existing Remarkable device token.."); 26 | this.deviceToken = deviceToken; 27 | } 28 | 29 | this.userToken = this.constructor._getUserToken(this.deviceToken); 30 | this.storageHost = this.constructor._getStorageHost(this.userToken); 31 | } 32 | 33 | 34 | // https://github.com/splitbrain/ReMarkableAPI/wiki/Authentication 35 | 36 | static _getDeviceToken(deviceId, oneTimeCode) { 37 | let data = { 38 | "code": oneTimeCode, // one-time code from website 39 | "deviceDesc": "desktop-windows", 40 | "deviceID": deviceId 41 | }; 42 | let options = { 43 | 'method': 'post', 44 | 'contentType': 'application/json', 45 | 'payload': JSON.stringify(data) 46 | }; 47 | // https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app 48 | let response = UrlFetchApp.fetch(`${AUTH_HOST}/token/json/2/device/new`, options); 49 | let deviceToken = response.getContentText() 50 | //Logger.log(`Received device token: ${deviceToken}`); 51 | return deviceToken; 52 | } 53 | 54 | static _getUserToken(deviceToken) { 55 | let options = { 56 | 'method': 'post', 57 | 'contentType': 'application/json', 58 | 'payload': JSON.stringify({}), 59 | 'headers': { 60 | 'Authorization': `Bearer ${deviceToken}` 61 | } 62 | }; 63 | let response = UrlFetchApp.fetch(`${AUTH_HOST}/token/json/2/user/new`, options); 64 | let userToken = response.getContentText() 65 | //Logger.log(`Received user Token: ${userToken}`); 66 | return userToken; 67 | } 68 | 69 | // https://github.com/splitbrain/ReMarkableAPI/wiki/Service-Discovery 70 | 71 | static _getStorageHost(userToken) { 72 | let options = { 73 | 'method': 'get', 74 | 'contentType': 'application/json', 75 | 'headers': { 76 | 'Authorization': `Bearer ${userToken}` 77 | } 78 | }; 79 | let response = UrlFetchApp.fetch('https://service-manager-production-dot-remarkable-production.appspot.com/service/json/1/document-storage?environment=production&group=auth0%7C5a68dc51cb30df3877a1d7c4&apiVer=2', options); 80 | let text = response.getContentText() 81 | let data = JSON.parse(text); 82 | if (data["Status"] == "OK") { 83 | Logger.log(`Remarkable cloud storage host: ${data["Host"]}`); 84 | return data["Host"]; 85 | } 86 | else { 87 | return null; 88 | } 89 | } 90 | 91 | // https://github.com/splitbrain/ReMarkableAPI/wiki/Storage 92 | 93 | listDocs(docUuid4 = null, withBlob = null) { 94 | Logger.log("Fetching doc list from Remarkable cloud"); 95 | let options = { 96 | 'method': 'get', 97 | 'contentType': 'application/json', 98 | 'headers': { 99 | 'Authorization': `Bearer ${this.userToken}` 100 | } 101 | }; 102 | let params = []; 103 | if (docUuid4 !== null) { 104 | params.push(`doc=${docUuid4}`); 105 | } 106 | if (withBlob !== null) { 107 | params.push(`withBlob=1`); 108 | } 109 | let urlParams = ""; 110 | if (params.length > 0) { 111 | urlParams = "?" + params.join("&"); 112 | } 113 | let response = UrlFetchApp.fetch(`https://${this.storageHost}/document-storage/json/2/docs${urlParams}`, options); 114 | let text = response.getContentText(); 115 | let data = JSON.parse(text); 116 | return data; 117 | } 118 | 119 | findDocUUID(name) { 120 | // TODO: should accept a path 121 | let allDocs = this.listDocs(); 122 | let filteredDocs = allDocs.filter((r) => r["VissibleName"] == name); 123 | if (filteredDocs.length > 0) { 124 | return filteredDocs[0]["ID"]; 125 | } 126 | else { 127 | return null; 128 | } 129 | } 130 | 131 | uploadRequest(data) { 132 | let payloadData = data.map((r) => ( 133 | ({ ID, Type, Version }) => ({ ID, Type, Version }))(r)); 134 | 135 | let options = { 136 | 'method': 'put', 137 | 'contentType': 'application/json', 138 | 'headers': { 139 | 'Authorization': `Bearer ${this.userToken}` 140 | }, 141 | 'payload': JSON.stringify(payloadData) 142 | }; 143 | let response = UrlFetchApp.fetch(`https://${this.storageHost}/document-storage/json/2/upload/request`, options); 144 | let text = response.getContentText() 145 | let res = JSON.parse(text); 146 | return res; 147 | } 148 | 149 | blobUpload(url, zipBlob) { 150 | let bytes = zipBlob.getBytes(); 151 | let options = { 152 | 'method': 'put', 153 | 'contentType': "", // needs to blank! 154 | 'contentLength': bytes.length, 155 | //'payload': bytes, 156 | 'payload': zipBlob, 157 | //'muteHttpExceptions': true // for debugging 158 | }; 159 | let response = UrlFetchApp.fetch(url, options); 160 | //let response = UrlFetchApp.getRequest(url, options); 161 | 162 | if (response.getResponseCode() != 200) { 163 | throw "Blob upload failed."; 164 | } 165 | } 166 | 167 | uploadUpdateStatus(data) { 168 | let payloadData = data.map((r) => ( 169 | ({ ID, Type, Version, Parent, VissibleName }) => ({ ID, Type, Version, Parent, VissibleName }))(r)); 170 | 171 | let options = { 172 | 'method': 'put', 173 | 'contentType': 'application/json', 174 | 'headers': { 175 | 'Authorization': `Bearer ${this.userToken}` 176 | }, 177 | 'payload': JSON.stringify(payloadData) 178 | }; 179 | let response = UrlFetchApp.fetch(`https://${this.storageHost}/document-storage/json/2/upload/update-status`, options); 180 | let text = response.getContentText() 181 | let res = JSON.parse(text); 182 | return res; 183 | } 184 | 185 | delete(data) { 186 | let payloadData = data.map((r) => ( 187 | ({ ID, Version }) => ({ ID, Version }))(r)); 188 | 189 | let options = { 190 | 'method': 'put', 191 | 'contentType': 'application/json', 192 | 'headers': { 193 | 'Authorization': `Bearer ${this.userToken}` 194 | }, 195 | 'payload': JSON.stringify(payloadData) 196 | }; 197 | let response = UrlFetchApp.fetch(`https://${this.storageHost}/document-storage/json/2/delete`, options); 198 | let text = response.getContentText() 199 | let res = JSON.parse(text); 200 | Logger.log(res); 201 | return res; 202 | } 203 | 204 | } 205 | -------------------------------------------------------------------------------- /Synchronizer.js: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/questions/23013573/swap-key-with-value-json/54207992#54207992 2 | const reverseDict = (o, r = {}) => Object.keys(o).map(x => r[o[x]] = x) && r; 3 | 4 | 5 | // https://github.com/30-seconds/30-seconds-of-code/blob/master/snippets/chunk.md 6 | const chunk = (arr, size) => 7 | Array.from({ length: Math.ceil(arr.length / size) }, (v, i) => 8 | arr.slice(i * size, i * size + size) 9 | ); 10 | 11 | // emulate python's pop 12 | const dictPop = (obj, key, def) => { 13 | if (key in obj) { 14 | let val = obj[key]; 15 | delete obj[key]; 16 | return val; 17 | } else if (def !== undefined) { 18 | return def; 19 | } else { 20 | throw `key ${key} not in dictionary` 21 | } 22 | } 23 | 24 | // https://stackoverflow.com/questions/7905929/how-to-test-valid-uuid-guid 25 | const isUUID = (uuid) => { 26 | let re = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; 27 | return re.test(uuid) 28 | } 29 | 30 | const rDeviceTokenKey = "__REMARKABLE_DEVICE_TOKEN__"; 31 | const rDeviceIdKey = "__REMARKABLE_DEVICE_ID__"; 32 | const availableModes = ["mirror", "update"]; 33 | 34 | /* Main work here. Walks Google Drive then uploads 35 | folder and files to Remarkable cloud storage. Currently 36 | only uploads PDFs/EPUBs. There appears to be a limitation 37 | with Remarkable that files must be less than 50MB so 38 | files greater than this size are filtered out. 39 | 40 | Arguments: 41 | 42 | rOneTimeCode - One time pass code from Remarkable that can typically 43 | be generated at https://my.remarkable.com/device/connect/mobile. 44 | gdFolderSearchParams - Google Drive search SDK string or folder id. 45 | rRootFolderName - The root folder in Remarkable device. Currently this 46 | must already exist on your device. This can be a remarkable 47 | folder GUID if you know it. 48 | syncMode - "mirror" or "update" (default). Mirroring will delete files 49 | in Remarkebale cloud that have been removed from Google Drive. 50 | gdFolderSkipList - Optional list of folder names to skip from syncing 51 | forceUpdateFunc - Optional function of obj dictionaries, the first generated 52 | from Google Drive, the second from Remarkable storage. The 53 | function returns true/false and determines whether you 54 | wish to bump up the version and force push. 55 | 56 | */ 57 | class Synchronizer { 58 | constructor(rOneTimeCode, gdFolderSearchParams, rRootFolderName, syncMode = "update", gdFolderSkipList = [], forceUpdateFunc = null) { 59 | 60 | // try finding google folder by id first 61 | try { 62 | this.gdFolder = DriveApp.getFolderById(gdFolderSearchParams); 63 | } catch (err) { 64 | let gdSearchFolders = DriveApp.searchFolders(gdFolderSearchParams); 65 | if (gdSearchFolders.hasNext()) { 66 | this.gdFolder = gdSearchFolders.next(); 67 | } else { 68 | throw `Could not find Google Drive folder using search params: ${gdFolderSearchParams}`; 69 | } 70 | } 71 | 72 | this.gdFolderSkipList = gdFolderSkipList; 73 | this.forceUpdateFunc = forceUpdateFunc; 74 | // we borrow terminology from https://freefilesync.org/manual.php?topic=synchronization-settings 75 | if (!availableModes.includes(syncMode)) { 76 | throw `syncMode '${syncMode}' not supported, try one from: ${availableModes}` 77 | } 78 | this.syncMode = syncMode; 79 | 80 | // for limits see https://developers.google.com/apps-script/guides/services/quotas 81 | this.userProps = PropertiesService.getUserProperties(); 82 | 83 | // these are read from and cached to this.userProps 84 | this.gdIdToUUID = this.userProps.getProperties(); 85 | 86 | // pop off keys not used for storing id/uuid mappings 87 | let rDeviceToken = dictPop(this.gdIdToUUID, rDeviceTokenKey, null); 88 | let rDeviceId = dictPop(this.gdIdToUUID, rDeviceIdKey, null); 89 | 90 | // for storing reverse map 91 | this.UUIDToGdId = reverseDict(this.gdIdToUUID); 92 | 93 | // initialize remarkable api 94 | if (rDeviceToken === null) { 95 | this.rApiClient = new RemarkableAPI(null, null, rOneTimeCode); 96 | this.userProps.setProperty(rDeviceTokenKey, this.rApiClient.deviceToken); 97 | this.userProps.setProperty(rDeviceIdKey, this.rApiClient.deviceId); 98 | } else { 99 | this.rApiClient = new RemarkableAPI(rDeviceId, rDeviceToken); 100 | } 101 | 102 | // prep some common vars 103 | this.rDocList = this.rApiClient.listDocs(); 104 | Logger.log(`Found ${this.rDocList.length} items in Remarkable Cloud`); 105 | 106 | // for debugging - dump doc list as json in root google drive folder 107 | //DriveApp.createFile('remarkableDocList.json', JSON.stringify(this.rDocList)); 108 | 109 | // create reverse dictionary 110 | this.rDocId2Ent = {} 111 | for (const [ix, doc] of this.rDocList.entries()) { 112 | this.rDocId2Ent[doc["ID"]] = ix; 113 | } 114 | 115 | // find root folder id 116 | if (isUUID(rRootFolderName)) { 117 | this.rRootFolderId = rRootFolderName; 118 | } else { 119 | let filteredDocs = this.rDocList.filter((r) => r["VissibleName"] == rRootFolderName); 120 | if (filteredDocs.length > 0) { 121 | this.rRootFolderId = filteredDocs[0]["ID"]; 122 | } 123 | else { 124 | // TODO if can't find it, create folder at top level with rRootFolderName 125 | throw `Cannot find root file '${rRootFolderName}'`; 126 | } 127 | } 128 | Logger.log(`Mapped '${rRootFolderName}' to ID '${this.rRootFolderId}'`); 129 | } 130 | 131 | getUUID(gdId) { 132 | if (!(gdId in this.gdIdToUUID)) { 133 | let uuid = Utilities.getUuid(); 134 | this.gdIdToUUID[gdId] = uuid; 135 | this.UUIDToGdId[uuid] = gdId; 136 | } 137 | return this.gdIdToUUID[gdId]; 138 | } 139 | 140 | generateZipBlob(gdFileId) { 141 | let uuid = this.getUUID(gdFileId); 142 | let gdFileObj = DriveApp.getFileById(gdFileId); 143 | let gdFileMT = gdFileObj.getMimeType(); 144 | 145 | if (gdFileMT == MimeType.SHORTCUT) { 146 | Logger.log(`Resolving shortcut to target file '${gdFileObj.getName()}'`); 147 | gdFileObj = DriveApp.getFileById(gdFileObj.getTargetId()); 148 | gdFileMT = gdFileObj.getMimeType(); 149 | } 150 | 151 | let zipBlob = null; 152 | 153 | if (gdFileMT == MimeType.FOLDER) { 154 | let contentBlob = Utilities.newBlob(JSON.stringify({})).setName(`${uuid}.content`); 155 | zipBlob = Utilities.zip([contentBlob]); 156 | } else { 157 | let gdFileExt = gdFileObj.getName().split('.').pop(); 158 | let gdFileBlob = gdFileObj.getBlob().setName(`${uuid}.${gdFileExt}`); 159 | let pdBlob = Utilities.newBlob("").setName(`${uuid}.pagedata`); 160 | let contentData = { 161 | 'extraMetadata': {}, 162 | 'fileType': gdFileExt, 163 | 'lastOpenedPage': 0, 164 | 'lineHeight': -1, 165 | 'margins': 100, 166 | 'pageCount': 0, // we don't know this, but it seems the reMarkable can count 167 | 'textScale': 1, 168 | 'transform': {} // no idea how to fill this, but it seems optional 169 | } 170 | let contentBlob = Utilities.newBlob(JSON.stringify(contentData)).setName(`${uuid}.content`); 171 | zipBlob = Utilities.zip([gdFileBlob, pdBlob, contentBlob]); 172 | } 173 | 174 | //DriveApp.createFile(zipBlob.setName(`rem-${uuid}.zip`)); // to debug/examine 175 | return zipBlob; 176 | } 177 | 178 | gdWalk(top, rParentId) { 179 | if (this.gdFolderSkipList.includes(top.getName())) { 180 | Logger.log(`Skipping Google Drive sub folder '${top.getName()}'`); 181 | return; 182 | } 183 | Logger.log(`Scanning Google Drive sub folder '${top.getName()}'`) 184 | let topUUID = this.getUUID(top.getId()); 185 | this.uploadDocList.push({ 186 | "ID": topUUID, 187 | "Type": "CollectionType", 188 | "Parent": rParentId, 189 | "VissibleName": top.getName(), 190 | "Version": 1, 191 | "_gdId": top.getId(), 192 | "_gdSize": top.getSize(), 193 | }); 194 | 195 | let files = top.getFiles(); 196 | while (files.hasNext()) { 197 | let file = files.next(); 198 | this.uploadDocList.push({ 199 | "ID": this.getUUID(file.getId()), 200 | "Type": "DocumentType", 201 | "Parent": topUUID, 202 | "VissibleName": file.getName(), 203 | "Version": 1, 204 | "_gdId": file.getId(), 205 | "_gdSize": file.getSize(), 206 | }); 207 | } 208 | 209 | let folders = top.getFolders(); 210 | while (folders.hasNext()) { 211 | let folder = folders.next(); 212 | this.gdWalk(folder, topUUID); 213 | } 214 | 215 | } 216 | 217 | // filter for upload list 218 | _needsUpdate(r) { 219 | if (r["ID"] in this.rDocId2Ent) { 220 | // update if parent or name differs 221 | let ix = this.rDocId2Ent[r["ID"]]; 222 | let s = this.rDocList[ix]; 223 | 224 | // force update 225 | if (this.forceUpdateFunc !== null && this.forceUpdateFunc(r, s)) { 226 | // bump up to server version 227 | r["Version"] = s["Version"] + 1; 228 | return true; 229 | } 230 | 231 | // verbose so can set breakpoints 232 | if (s["Parent"] != r["Parent"] || s["VissibleName"] != r["VissibleName"]) { 233 | // bump up to server version 234 | r["Version"] = s["Version"] + 1; 235 | return true; 236 | } else { 237 | return false; 238 | } 239 | } 240 | else { 241 | // 50MB = 50 * 1024*1024 = 52428800 242 | if (r["Type"] == "DocumentType" 243 | && (r["VissibleName"].endsWith("pdf") || r["VissibleName"].endsWith("epub")) 244 | && r["_gdSize"] <= 52428800) { 245 | return true; 246 | } else if (r["Type"] == "CollectionType") { 247 | return true; 248 | } else { 249 | return false; 250 | } 251 | } 252 | } 253 | 254 | rAllDescendantIds() { 255 | // returns list of IDs all decendants 256 | let collected = []; 257 | let that = this; 258 | function _walkDocList(parentId) { 259 | collected.push(parentId); 260 | let children = that.rDocList.filter((r) => r.Parent == parentId).map((r) => _walkDocList(r.ID)); 261 | } 262 | _walkDocList(this.rRootFolderId); 263 | // remove the parentId (this typically won't come from Google Drive) 264 | return collected.filter(x => x !== this.rRootFolderId); 265 | } 266 | 267 | run() { 268 | try { 269 | // store all objects in this 270 | this.uploadDocList = []; 271 | 272 | // generate list from google drive 273 | Logger.log(`Scanning Google Drive folder '${this.gdFolder.getName()}'..`) 274 | this.gdWalk(this.gdFolder, this.rRootFolderId); 275 | Logger.log(`Found ${this.uploadDocList.length} items in Google Drive folder.`) 276 | 277 | // for debugging - dump upload doc list as json in root google drive folder 278 | //DriveApp.createFile('googleDriveDocList.json', JSON.stringify(this.uploadDocList)); 279 | 280 | // save new user properties 281 | this.userProps.setProperties(this.gdIdToUUID); 282 | 283 | // remove files from device no longer in google drive 284 | if (this.syncMode === "mirror") { 285 | Logger.log("In mirror mode. Will delete files on Remarkable not on Google Drive."); 286 | let rDescIds = new Set(this.rAllDescendantIds()); 287 | let gdIds = new Set(this.uploadDocList.map((r) => r.ID)); 288 | let diff = rDescIds.difference(gdIds); 289 | let deleteList = this.rDocList.filter((r) => diff.has(r.ID)); 290 | deleteList.forEach((r) => { 291 | Logger.log(`Adding for deletion: ${r["VissibleName"]}`); 292 | }); 293 | if (deleteList.length > 0) { 294 | Logger.log(`Deleting ${deleteList.length} docs that no longer exist in Google Drive`); 295 | this.rApiClient.delete(deleteList); 296 | } 297 | } 298 | 299 | // filter those that need update 300 | let updateDocList = this.uploadDocList.filter((r) => this._needsUpdate(r)); 301 | Logger.log(`Updating ${updateDocList.length} documents and folders..`) 302 | 303 | // chunk into 5 files at a time a loop 304 | for (const uploadDocChunk of chunk(updateDocList, 5)) { 305 | Logger.info(`Processing chunk of size ${uploadDocChunk.length}..`) 306 | 307 | // extract data for registration 308 | let uploadRequestResults = this.rApiClient.uploadRequest(uploadDocChunk); 309 | 310 | // upload files 311 | let deleteDocList = []; 312 | for (const doc of uploadRequestResults) { 313 | if (doc["Success"]) { 314 | try { 315 | let gdFileId = this.UUIDToGdId[doc["ID"]]; 316 | let gdFileObj = DriveApp.getFileById(gdFileId); 317 | Logger.log(`Attempting to upload '${gdFileObj.getName()}'; size ${gdFileObj.getSize()} bytes`); 318 | let gdFileBlob = this.generateZipBlob(gdFileId); 319 | Logger.log(`Generated Remarkable zip blob for '${gdFileObj.getName()}'`); 320 | this.rApiClient.blobUpload(doc["BlobURLPut"], gdFileBlob); 321 | Logger.log(`Uploaded '${gdFileObj.getName()}'`); 322 | } 323 | catch (err) { 324 | Logger.log(`Failed to upload '${doc["ID"]}': ${err}`); 325 | deleteDocList.push(doc); 326 | } 327 | } 328 | } 329 | 330 | // update metadata 331 | Logger.info("Updating meta data for chunk"); 332 | let uploadUpdateStatusResults = this.rApiClient.uploadUpdateStatus(uploadDocChunk); 333 | for (const r of uploadUpdateStatusResults) { 334 | if (!r["Success"]) { 335 | let ix = this.rDocId2Ent[r["ID"]]; 336 | let s = this.rDocList[ix]; 337 | Logger.log(`Failed to update status '${s["VissibleName"]}': ${r["Message"]}`) 338 | } 339 | } 340 | 341 | // delete failed uploads 342 | // do this after meta data update to ensure version matches. 343 | if (deleteDocList.length > 0) { 344 | Logger.log(`Deleting ${deleteDocList.length} docs that failed to upload`); 345 | this.rApiClient.delete(deleteDocList); 346 | } 347 | 348 | Logger.info("Finished processing chunk."); 349 | } 350 | 351 | Logger.info("Finished running!"); 352 | } 353 | catch (err) { 354 | Logger.log(`Finished run with error: ${err}`); 355 | } 356 | } 357 | 358 | } --------------------------------------------------------------------------------