├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── Archive.ts ├── DiffZipSettingTab.ts ├── LICENSE ├── ObsHttpHandler.ts ├── README.md ├── RestoreFileInfo.svelte ├── RestoreFiles.svelte ├── RestoreView.ts ├── dialog.ts ├── esbuild.config.mjs ├── main.ts ├── manifest.json ├── package.json ├── pnpm-lock.yaml ├── storage.ts ├── styles.css ├── tsconfig.json ├── types.ts ├── util.ts ├── version-bump.mjs └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | main.js 3 | data.json 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian Plugin 2 | on: 3 | push: 4 | # Sequence of patterns matched against refs/tags 5 | tags: 6 | - '*' # Push events to matching any tag format, i.e. 1.0, 20.15.10 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 0 # otherwise, you will failed to push refs to dest repo 16 | submodules: recursive 17 | - name: Use Node.js 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: '20.x' # You might need to adjust this value to your own version 21 | # Get the version number and put it in a variable 22 | - name: Get Version 23 | id: version 24 | run: | 25 | echo "::set-output name=tag::$(git describe --abbrev=0 --tags)" 26 | 27 | - uses: pnpm/action-setup@v4 28 | name: Install pnpm 29 | with: 30 | version: 9 31 | run_install: false 32 | 33 | - name: Get pnpm store directory 34 | shell: bash 35 | run: | 36 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 37 | 38 | - uses: actions/cache@v3 39 | name: Setup pnpm cache 40 | with: 41 | path: ${{ env.STORE_PATH }} 42 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 43 | restore-keys: | 44 | ${{ runner.os }}-pnpm-store- 45 | 46 | - name: Build 47 | id: build 48 | run: | 49 | pnpm install --frozen-lockfile 50 | pnpm build 51 | # Package the required files into a zip 52 | - name: Package 53 | run: | 54 | mkdir ${{ github.event.repository.name }} 55 | cp main.js manifest.json README.md ${{ github.event.repository.name }} 56 | zip -r ${{ github.event.repository.name }}.zip ${{ github.event.repository.name }} 57 | # Create the release on github 58 | - name: Create Release 59 | id: create_release 60 | uses: actions/create-release@v1 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | VERSION: ${{ github.ref }} 64 | with: 65 | tag_name: ${{ github.ref }} 66 | release_name: ${{ github.ref }} 67 | draft: true 68 | prerelease: false 69 | # Upload the packaged release file 70 | - name: Upload zip file 71 | id: upload-zip 72 | uses: actions/upload-release-asset@v1 73 | env: 74 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 75 | with: 76 | upload_url: ${{ steps.create_release.outputs.upload_url }} 77 | asset_path: ./${{ github.event.repository.name }}.zip 78 | asset_name: ${{ github.event.repository.name }}-${{ steps.version.outputs.tag }}.zip 79 | asset_content_type: application/zip 80 | # Upload the main.js 81 | - name: Upload main.js 82 | id: upload-main 83 | uses: actions/upload-release-asset@v1 84 | env: 85 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 86 | with: 87 | upload_url: ${{ steps.create_release.outputs.upload_url }} 88 | asset_path: ./main.js 89 | asset_name: main.js 90 | asset_content_type: text/javascript 91 | # Upload the manifest.json 92 | - name: Upload manifest.json 93 | id: upload-manifest 94 | uses: actions/upload-release-asset@v1 95 | env: 96 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 97 | with: 98 | upload_url: ${{ steps.create_release.outputs.upload_url }} 99 | asset_path: ./manifest.json 100 | asset_name: manifest.json 101 | asset_content_type: application/json 102 | # Upload the style.css 103 | - name: Upload styles.css 104 | id: upload-css 105 | uses: actions/upload-release-asset@v1 106 | env: 107 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 108 | with: 109 | upload_url: ${{ steps.create_release.outputs.upload_url }} 110 | asset_path: ./styles.css 111 | asset_name: styles.css 112 | asset_content_type: text/css 113 | # TODO: release notes??? 114 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "printWidth": 120, 6 | "semi": true, 7 | "endOfLine": "auto" 8 | } 9 | -------------------------------------------------------------------------------- /Archive.ts: -------------------------------------------------------------------------------- 1 | import * as fflate from "fflate"; 2 | import { promiseWithResolver } from "octagonal-wheels/promises"; 3 | 4 | /** 5 | * A class to archive files 6 | */ 7 | export class Archiver { 8 | _zipFile: fflate.Zip; 9 | _aborted: boolean = false; 10 | _output: Uint8Array[] = []; 11 | _processedCount: number = 0; 12 | _processedLength: number = 0; 13 | _archivedCount: number = 0; 14 | _archiveSize: number = 0; 15 | 16 | progressReport(type: string) { 17 | // console.warn( 18 | // `Archiver: ${type} processed: ${this._processedCount} (${this._processedLength} bytes) ${this._archivedCount} (${this._archiveSize} bytes)` 19 | // ) 20 | } 21 | 22 | _zipFilePromise = promiseWithResolver(); 23 | get archivedZipFile(): Promise { 24 | return this._zipFilePromise.promise; 25 | } 26 | 27 | get currentSize(): number { 28 | return this._output.reduce((acc, val) => acc + val.length, 0); 29 | } 30 | 31 | constructor() { 32 | const zipFile = new fflate.Zip(async (error, dat, final) => this._onProgress(error, dat, final)); 33 | this._zipFile = zipFile; 34 | } 35 | 36 | _onProgress(err: fflate.FlateError | null, data: Uint8Array, final: boolean) { 37 | if (err) return this._onError(err); 38 | if (data && data.length > 0) { 39 | this._output.push(data); 40 | this._archiveSize += data.length; 41 | } 42 | // No error 43 | this.progressReport("progress"); 44 | if (this._aborted) return this._onAborted(); 45 | if (final) void this._onFinalise(); 46 | } 47 | 48 | async _onFinalise(): Promise { 49 | this._zipFile.terminate(); 50 | const out = new Blob(this._output, { type: "application/zip" }); 51 | const result = new Uint8Array(await out.arrayBuffer()); 52 | this._zipFilePromise.resolve(result); 53 | } 54 | 55 | _onAborted() { 56 | this._zipFile.terminate(); 57 | this._zipFilePromise.reject(new Error("Aborted")); 58 | } 59 | 60 | _onError(err: fflate.FlateError): void { 61 | this._zipFile.terminate(); 62 | this._zipFilePromise.reject(err); 63 | } 64 | 65 | addTextFile(text: string, path: string, options?: { mtime?: number }): void { 66 | const binary = new TextEncoder().encode(text); 67 | this.addFile(binary, path, options); 68 | } 69 | 70 | addFile(file: Uint8Array, path: string, options?: { mtime?: number }): void { 71 | const fflateFile = new fflate.ZipDeflate(path, { level: 9 }); 72 | fflateFile.mtime = options?.mtime ?? Date.now(); 73 | this._processedLength += file.length; 74 | this.progressReport("add"); 75 | this._zipFile.add(fflateFile); 76 | 77 | // TODO: Check if the large file can be added in a single chunks 78 | fflateFile.push(file, true); 79 | } 80 | 81 | finalize() { 82 | this._zipFile.end(); 83 | return this.archivedZipFile; 84 | } 85 | } 86 | 87 | /** 88 | * A class to extract files from a zip archive 89 | */ 90 | export class Extractor { 91 | _zipFile: fflate.Unzip; 92 | _isFileShouldBeExtracted: (file: fflate.UnzipFile) => boolean | Promise; 93 | _onExtracted: (filename: string, content: Uint8Array) => Promise; 94 | 95 | constructor(isFileShouldBeExtracted: Extractor["_isFileShouldBeExtracted"], callback: Extractor["_onExtracted"]) { 96 | const unzipper = new fflate.Unzip(); 97 | unzipper.register(fflate.UnzipInflate); 98 | this._zipFile = unzipper; 99 | this._isFileShouldBeExtracted = isFileShouldBeExtracted; 100 | this._onExtracted = callback; 101 | unzipper.onfile = async (file: fflate.UnzipFile) => { 102 | if (await this._isFileShouldBeExtracted(file)) { 103 | const data: Uint8Array[] = []; 104 | file.ondata = async (err, dat, isFinal) => { 105 | if (err) { 106 | console.error("Error extracting file", err); 107 | return; 108 | } 109 | if (dat && dat.length > 0) data.push(dat); 110 | 111 | if (isFinal) { 112 | const total = new Blob(data, { type: "application/octet-stream" }); 113 | const result = new Uint8Array(await total.arrayBuffer()); 114 | await this._onExtracted(file.name, result); 115 | } 116 | }; 117 | file.start(); 118 | } 119 | }; 120 | } 121 | 122 | addZippedContent(data: Uint8Array, isFinal = false) { 123 | this._zipFile.push(data, isFinal); 124 | } 125 | 126 | finalise() { 127 | this._zipFile.push(new Uint8Array(), true); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /DiffZipSettingTab.ts: -------------------------------------------------------------------------------- 1 | import { PluginSettingTab, type App, Setting, Notice } from "obsidian"; 2 | import { encrypt, decrypt } from "octagonal-wheels/encryption.js"; 3 | import DiffZipBackupPlugin from "./main"; 4 | import { askSelectString } from "dialog"; 5 | import { S3Bucket } from "./storage"; 6 | import { AutoBackupType } from "./types"; 7 | 8 | export class DiffZipSettingTab extends PluginSettingTab { 9 | plugin: DiffZipBackupPlugin; 10 | 11 | constructor(app: App, plugin: DiffZipBackupPlugin) { 12 | super(app, plugin); 13 | this.plugin = plugin; 14 | } 15 | 16 | display(): void { 17 | const { containerEl } = this; 18 | containerEl.empty(); 19 | containerEl.createEl("h2", { text: "General" }); 20 | 21 | new Setting(containerEl).setName("Start backup at launch").addToggle((toggle) => 22 | toggle.setValue(this.plugin.settings.startBackupAtLaunch).onChange(async (value) => { 23 | this.plugin.settings.startBackupAtLaunch = value; 24 | await this.plugin.saveSettings(); 25 | }) 26 | ); 27 | 28 | new Setting(containerEl) 29 | .setName("Auto backup style") 30 | .setDesc("If you want to backup automatically, select the type of backup") 31 | .addDropdown((dropdown) => 32 | dropdown 33 | .addOption(AutoBackupType.FULL, "Full") 34 | .addOption(AutoBackupType.ONLY_NEW, "Only New") 35 | .addOption(AutoBackupType.ONLY_NEW_AND_EXISTING, "Non-destructive") 36 | .setValue(this.plugin.settings.startBackupAtLaunchType) 37 | .onChange(async (value) => { 38 | this.plugin.settings.startBackupAtLaunchType = value as AutoBackupType; 39 | await this.plugin.saveSettings(); 40 | }) 41 | ); 42 | new Setting(containerEl) 43 | .setName("Include hidden folders") 44 | .setDesc("node_modules, .git, and trash of Obsidian are ignored automatically") 45 | .addToggle((toggle) => 46 | toggle.setValue(this.plugin.settings.includeHiddenFolder).onChange(async (value) => { 47 | this.plugin.settings.includeHiddenFolder = value; 48 | await this.plugin.saveSettings(); 49 | }) 50 | ); 51 | 52 | containerEl.createEl("h2", { text: "Backup Destination" }); 53 | const dropDownRemote: Record = { 54 | "": "Inside the vault", 55 | desktop: "Anywhere (Desktop only)", 56 | s3: "S3 Compatible Bucket", 57 | }; 58 | if (this.plugin.isMobile) delete dropDownRemote.desktop; 59 | let backupDestination = this.plugin.settings.desktopFolderEnabled 60 | ? "desktop" 61 | : this.plugin.settings.bucketEnabled 62 | ? "s3" 63 | : ""; 64 | 65 | new Setting(containerEl) 66 | .setName("Backup Destination") 67 | .setDesc("Select where to save the backup") 68 | .addDropdown((dropdown) => 69 | dropdown 70 | .addOptions(dropDownRemote) 71 | .setValue(backupDestination) 72 | .onChange(async (value) => { 73 | backupDestination = value; 74 | this.plugin.settings.desktopFolderEnabled = value == "desktop"; 75 | this.plugin.settings.bucketEnabled = value == "s3"; 76 | await this.plugin.saveSettings(); 77 | this.display(); 78 | }) 79 | ); 80 | 81 | if (backupDestination == "desktop") { 82 | new Setting(containerEl) 83 | .setName("Backup folder (desktop)") 84 | .setDesc( 85 | "We can use external folder of Obsidian only if on desktop and it is enabled. This feature uses Internal API." 86 | ) 87 | .addText((text) => 88 | text 89 | .setPlaceholder("c:\\temp\\backup") 90 | .setValue(this.plugin.settings.BackupFolderDesktop) 91 | .setDisabled(!this.plugin.settings.desktopFolderEnabled) 92 | .onChange(async (value) => { 93 | this.plugin.settings.BackupFolderDesktop = value; 94 | await this.plugin.saveSettings(); 95 | }) 96 | ); 97 | } else if (backupDestination == "s3") { 98 | new Setting(containerEl) 99 | .setName("Endpoint") 100 | .setDesc("endPoint is a host name or an IP address") 101 | .addText((text) => 102 | text 103 | .setPlaceholder("play.min.io") 104 | .setValue(this.plugin.settings.endPoint) 105 | .onChange(async (value) => { 106 | this.plugin.settings.endPoint = value; 107 | await this.plugin.saveSettings(); 108 | }) 109 | ); 110 | new Setting(containerEl).setName("AccessKey").addText((text) => 111 | text 112 | .setPlaceholder("Q3................2F") 113 | .setValue(this.plugin.settings.accessKey) 114 | .onChange(async (value) => { 115 | this.plugin.settings.accessKey = value; 116 | await this.plugin.saveSettings(); 117 | }) 118 | ); 119 | new Setting(containerEl).setName("SecretKey").addText((text) => 120 | text 121 | .setPlaceholder("zuf...................................TG") 122 | .setValue(this.plugin.settings.secretKey) 123 | .onChange(async (value) => { 124 | this.plugin.settings.secretKey = value; 125 | await this.plugin.saveSettings(); 126 | }) 127 | ); 128 | new Setting(containerEl).setName("Region").addText((text) => 129 | text 130 | .setPlaceholder("us-east-1") 131 | .setValue(this.plugin.settings.region) 132 | .onChange(async (value) => { 133 | this.plugin.settings.region = value; 134 | await this.plugin.saveSettings(); 135 | }) 136 | ); 137 | new Setting(containerEl).setName("Bucket").addText((text) => 138 | text.setValue(this.plugin.settings.bucket).onChange(async (value) => { 139 | this.plugin.settings.bucket = value; 140 | await this.plugin.saveSettings(); 141 | }) 142 | ); 143 | new Setting(containerEl) 144 | .setName("Use Custom HTTP Handler") 145 | .setDesc("If you are using a custom HTTP handler, enable this option.") 146 | .addToggle((toggle) => 147 | toggle.setValue(this.plugin.settings.useCustomHttpHandler).onChange(async (value) => { 148 | this.plugin.settings.useCustomHttpHandler = value; 149 | await this.plugin.saveSettings(); 150 | }) 151 | ); 152 | new Setting(containerEl) 153 | .setName("Test and Initialise") 154 | .addButton((button) => 155 | button.setButtonText("Test").onClick(async () => { 156 | const testS3Adapter = new S3Bucket(this.plugin); 157 | const client = await testS3Adapter.getClient(); 158 | try { 159 | const buckets = await client.listBuckets(); 160 | if (buckets.Buckets?.map((e) => e.Name).indexOf(this.plugin.settings.bucket) !== -1) { 161 | new Notice("Connection is successful, and bucket is existing"); 162 | } else { 163 | new Notice("Connection is successful, aut bucket is missing"); 164 | } 165 | } catch (ex) { 166 | console.dir(ex); 167 | new Notice("Connection failed"); 168 | } 169 | }) 170 | ) 171 | .addButton((button) => 172 | button.setButtonText("Create Bucket").onClick(async () => { 173 | const testS3Adapter = new S3Bucket(this.plugin); 174 | const client = await testS3Adapter.getClient(); 175 | try { 176 | await client.createBucket({ 177 | Bucket: this.plugin.settings.bucket, 178 | CreateBucketConfiguration: {}, 179 | }); 180 | new Notice("Bucket has been created"); 181 | } catch (ex) { 182 | new Notice(`Bucket creation failed\n-----\n${ex?.message ?? "Unknown error"}`); 183 | console.dir(ex); 184 | } 185 | }) 186 | ); 187 | new Setting(containerEl) 188 | .setName("Backup folder") 189 | .setDesc("Folder to keep each backup ZIPs and information file") 190 | .addText((text) => 191 | text 192 | .setPlaceholder("backup") 193 | .setValue(this.plugin.settings.backupFolderBucket) 194 | .onChange(async (value) => { 195 | this.plugin.settings.backupFolderBucket = value; 196 | await this.plugin.saveSettings(); 197 | }) 198 | ); 199 | } else { 200 | new Setting(containerEl) 201 | .setName("Backup folder") 202 | .setDesc("Folder to keep each backup ZIPs and information file") 203 | .addText((text) => 204 | text 205 | .setPlaceholder("backup") 206 | .setValue(this.plugin.settings.backupFolderMobile) 207 | .onChange(async (value) => { 208 | this.plugin.settings.backupFolderMobile = value; 209 | await this.plugin.saveSettings(); 210 | }) 211 | ); 212 | } 213 | 214 | containerEl.createEl("h2", { text: "Restore" }); 215 | 216 | new Setting(containerEl) 217 | .setName("Restore folder") 218 | .setDesc("Folder to save the restored file (Not applied on folder restore)") 219 | .addText((text) => 220 | text 221 | .setPlaceholder("restored") 222 | .setValue(this.plugin.settings.restoreFolder) 223 | .onChange(async (value) => { 224 | this.plugin.settings.restoreFolder = value; 225 | await this.plugin.saveSettings(); 226 | }) 227 | ); 228 | 229 | containerEl.createEl("h2", { text: "Backup ZIP Settings" }); 230 | new Setting(containerEl) 231 | .setName("Max files in a single ZIP") 232 | .setDesc("(0 to disabled) Limit the number of files in a single ZIP file to better restore performance") 233 | .addText((text) => 234 | text 235 | .setPlaceholder("100") 236 | .setValue(this.plugin.settings.maxFilesInZip + "") 237 | .onChange(async (value) => { 238 | this.plugin.settings.maxFilesInZip = Number.parseInt(value); 239 | await this.plugin.saveSettings(); 240 | }) 241 | ); 242 | new Setting(containerEl) 243 | .setName("Perform all files over the max files") 244 | .setDesc( 245 | "Automatically process the remaining files, even if the number of files to be processed exceeds Max files." 246 | ) 247 | .addToggle((toggle) => 248 | toggle.setValue(this.plugin.settings.performNextBackupOnMaxFiles).onChange(async (value) => { 249 | this.plugin.settings.performNextBackupOnMaxFiles = value; 250 | await this.plugin.saveSettings(); 251 | }) 252 | ); 253 | 254 | new Setting(containerEl) 255 | .setName("Max size of each output ZIP file") 256 | .setDesc("(MB) Size to split the backup zip file. Unzipping requires 7z or other compatible tools.") 257 | .addText((text) => 258 | text 259 | .setPlaceholder("30") 260 | .setValue(this.plugin.settings.maxSize + "") 261 | .onChange(async (value) => { 262 | this.plugin.settings.maxSize = Number.parseInt(value); 263 | await this.plugin.saveSettings(); 264 | }) 265 | ); 266 | 267 | containerEl.createEl("h2", { text: "Misc" }); 268 | 269 | new Setting(containerEl) 270 | .setName("Reset Backup Information") 271 | .setDesc("After resetting, backup information will be lost.") 272 | .addButton((button) => 273 | button 274 | .setWarning() 275 | .setButtonText("Reset") 276 | .onClick(async () => { 277 | this.plugin.resetToC(); 278 | }) 279 | ); 280 | new Setting(containerEl) 281 | .setName("Encryption") 282 | .setDesc( 283 | "Warning: This is not compatible with the usual ZIP tools. You can decrypt each file using OpenSSL with openssl openssl enc -aes-256-cbc -in [file] -k [passphrase] -pbkdf2 -d -md sha256 > [out]" 284 | ) 285 | .addText( 286 | (text) => 287 | (text 288 | .setPlaceholder("Passphrase") 289 | .setValue(this.plugin.settings.passphraseOfZip) 290 | .onChange(async (value) => { 291 | this.plugin.settings.passphraseOfZip = value; 292 | await this.plugin.saveSettings(); 293 | }).inputEl.type = "password") 294 | ); 295 | 296 | containerEl.createEl("h2", { text: "Tools" }); 297 | let passphrase = ""; 298 | new Setting(containerEl) 299 | .setName("Passphrase") 300 | .setDesc("You can encrypt the settings with a passphrase") 301 | .addText( 302 | (text) => 303 | (text 304 | .setPlaceholder("Passphrase") 305 | .setValue(passphrase) 306 | .onChange(async (value) => { 307 | passphrase = value; 308 | await this.plugin.saveSettings(); 309 | }).inputEl.type = "password") 310 | ); 311 | 312 | new Setting(containerEl) 313 | .setName("Copy setting to another device via URI") 314 | .setDesc("You can copy the settings to another device by URI") 315 | .addButton((button) => { 316 | button.setButtonText("Copy to Clipboard").onClick(async () => { 317 | const setting = JSON.stringify(this.plugin.settings); 318 | const encrypted = await encrypt(setting, passphrase, false); 319 | const uri = `obsidian://diffzip/settings?data=${encodeURIComponent(encrypted)}`; 320 | await navigator.clipboard.writeText(uri); 321 | new Notice("URI has been copied to the clipboard"); 322 | }); 323 | }); 324 | 325 | let copiedURI = ""; 326 | new Setting(containerEl) 327 | .setName("Paste setting from another device") 328 | .setDesc("You can paste the settings from another device by URI") 329 | .addText((text) => { 330 | text.setPlaceholder("obsidian://diffzip/settings?data=....") 331 | .setValue(copiedURI) 332 | .onChange(async (value) => { 333 | copiedURI = value; 334 | }); 335 | }) 336 | .addButton((button) => { 337 | button.setButtonText("Apply"); 338 | button.setWarning(); 339 | button.onClick(async () => { 340 | const uri = copiedURI; 341 | const data = decodeURIComponent(uri.split("?data=")[1]); 342 | try { 343 | const decrypted = await decrypt(data, passphrase, false); 344 | const settings = JSON.parse(decrypted); 345 | if ( 346 | (await askSelectString(this.app, "Are you sure to overwrite the settings?", [ 347 | "Yes", 348 | "No", 349 | ])) == "Yes" 350 | ) { 351 | Object.assign(this.plugin.settings, settings); 352 | await this.plugin.saveSettings(); 353 | this.display(); 354 | } else { 355 | new Notice("Cancelled"); 356 | } 357 | } catch (e) { 358 | new Notice("Failed to decrypt the settings"); 359 | console.warn(e); 360 | } 361 | }); 362 | }); 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 vorotamoroz 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 | -------------------------------------------------------------------------------- /ObsHttpHandler.ts: -------------------------------------------------------------------------------- 1 | // This file is based on a file that was published by the @remotely-save, under the Apache 2 License. 2 | // I would love to express my deepest gratitude to the original authors for their hard work and dedication. Without their contributions, this project would not have been possible. 3 | // 4 | // Original Implementation is here: https://github.com/remotely-save/remotely-save/blob/28b99557a864ef59c19d2ad96101196e401718f0/src/remoteForS3.ts 5 | 6 | import { FetchHttpHandler, type FetchHttpHandlerOptions } from "@smithy/fetch-http-handler"; 7 | import { HttpRequest, HttpResponse, type HttpHandlerOptions } from "@smithy/protocol-http"; 8 | //@ts-ignore 9 | import { requestTimeout } from "@smithy/fetch-http-handler/dist-es/request-timeout"; 10 | import { buildQueryString } from "@smithy/querystring-builder"; 11 | import { requestUrl, type RequestUrlParam } from "obsidian"; 12 | //////////////////////////////////////////////////////////////////////////////// 13 | // special handler using Obsidian requestUrl 14 | //////////////////////////////////////////////////////////////////////////////// 15 | 16 | /** 17 | * This is close to origin implementation of FetchHttpHandler 18 | * https://github.com/aws/aws-sdk-js-v3/blob/main/packages/fetch-http-handler/src/fetch-http-handler.ts 19 | * that is released under Apache 2 License. 20 | * But this uses Obsidian requestUrl instead. 21 | */ 22 | export class ObsHttpHandler extends FetchHttpHandler { 23 | requestTimeoutInMs: number | undefined; 24 | reverseProxyNoSignUrl: string | undefined; 25 | constructor(options?: FetchHttpHandlerOptions, reverseProxyNoSignUrl?: string) { 26 | super(options); 27 | this.requestTimeoutInMs = options === undefined ? undefined : options.requestTimeout; 28 | this.reverseProxyNoSignUrl = reverseProxyNoSignUrl; 29 | } 30 | // eslint-disable-next-line require-await 31 | async handle(request: HttpRequest, { abortSignal }: HttpHandlerOptions = {}): Promise<{ response: HttpResponse }> { 32 | if (abortSignal?.aborted) { 33 | const abortError = new Error("Request aborted"); 34 | abortError.name = "AbortError"; 35 | return Promise.reject(abortError); 36 | } 37 | 38 | let path = request.path; 39 | if (request.query) { 40 | const queryString = buildQueryString(request.query); 41 | if (queryString) { 42 | path += `?${queryString}`; 43 | } 44 | } 45 | 46 | const { port, method } = request; 47 | let url = `${request.protocol}//${request.hostname}${port ? `:${port}` : ""}${path}`; 48 | if (this.reverseProxyNoSignUrl !== undefined && this.reverseProxyNoSignUrl !== "") { 49 | const urlObj = new URL(url); 50 | urlObj.host = this.reverseProxyNoSignUrl; 51 | url = urlObj.href; 52 | } 53 | const body = method === "GET" || method === "HEAD" ? undefined : request.body; 54 | 55 | const transformedHeaders: Record = {}; 56 | for (const key of Object.keys(request.headers)) { 57 | const keyLower = key.toLowerCase(); 58 | if (keyLower === "host" || keyLower === "content-length") { 59 | continue; 60 | } 61 | transformedHeaders[keyLower] = request.headers[key]; 62 | } 63 | 64 | let contentType: string | undefined = undefined; 65 | if (transformedHeaders["content-type"] !== undefined) { 66 | contentType = transformedHeaders["content-type"]; 67 | } 68 | 69 | let transformedBody: any = body; 70 | if (ArrayBuffer.isView(body)) { 71 | transformedBody = new Uint8Array(body.buffer).buffer; 72 | } 73 | 74 | const param: RequestUrlParam = { 75 | body: transformedBody, 76 | headers: transformedHeaders, 77 | method: method, 78 | url: url, 79 | contentType: contentType, 80 | }; 81 | 82 | const raceOfPromises = [ 83 | requestUrl(param).then((rsp) => { 84 | const headers = rsp.headers; 85 | const headersLower: Record = {}; 86 | for (const key of Object.keys(headers)) { 87 | headersLower[key.toLowerCase()] = headers[key]; 88 | } 89 | const stream = new ReadableStream({ 90 | start(controller) { 91 | controller.enqueue(new Uint8Array(rsp.arrayBuffer)); 92 | controller.close(); 93 | }, 94 | }); 95 | return { 96 | response: new HttpResponse({ 97 | headers: headersLower, 98 | statusCode: rsp.status, 99 | body: stream, 100 | }), 101 | }; 102 | }), 103 | requestTimeout(this.requestTimeoutInMs), 104 | ]; 105 | 106 | if (abortSignal) { 107 | raceOfPromises.push( 108 | new Promise((resolve, reject) => { 109 | abortSignal.onabort = () => { 110 | const abortError = new Error("Request aborted"); 111 | abortError.name = "AbortError"; 112 | reject(abortError); 113 | }; 114 | }) 115 | ); 116 | } 117 | return Promise.race(raceOfPromises); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Differential ZIP Backup 2 | 3 | ![screenshot](https://github.com/vrtmrz/diffzip/assets/45774780/19ac3972-70e1-462b-b26f-28e7c0f69655) 4 | 5 | This is a vault backup plugin for [Obsidian](https://obsidian.md). 6 | 7 | We can store all the files which have been modified, into a ZIP file. 8 | 9 | ## Installation 10 | 11 | 1. Install this plug-in from [Beta Reviewers Auto-update Tester](https://github.com/TfTHacker/obsidian42-brat). 12 | 13 | ## How to use 14 | 15 | ### Making backup 16 | 1. Perform `Create Differential Backup` from the command palette. 17 | 2. We will get `backupinfo.md` and a zip file `YYYY-MM-DD-SECONDS.zip` in the `backup` folder 18 | - `backup` folder can be configured in the settings dialogue. 19 | 20 | ### Restore a file 21 | 1. Perform `Restore from backups` from the command palette. 22 | 2. Select the file you want to restore. 23 | 3. Select the backup you want to restore. 24 | 4. Select the place to save the restored file. 25 | 5. We got an old file. 26 | 27 | ## Settings 28 | 29 | 30 | ### General 31 | 32 | | Key | Description | 33 | | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 34 | | Start backup at launch | When the plug-in has been loaded, Differential backup will be created automatically. | 35 | | Auto backup style | Check differences to... `Full` to all files, `Only new` to the files which were newer than the backup, `Non-destructive` as same as Only new but not includes the deletion. | 36 | | Include hidden folder | Backup also the configurations, plugins, themes, and, snippets. | 37 | | Backup Destination | Where to save the backup `Inside the vault`, `Anywhere (Desktop only)`, and `S3 bucket` are available. `Anywhere` is on the bleeding edge. Not safe. Only available on desktop devices. | 38 | | Restore folder | The folder which restored files will be stored. | 39 | | Max files in a single zip | How many files are stored in a single ZIP file. | 40 | | Perform all files over the max files | Automatically process the remaining files, even if the number of files to be processed exceeds Max files. | 41 | | ZIP splitting size | An large file are not good for handling, so this plug-in splits the backup ZIP into this size. This splitted ZIP files can be handled by 7Z or something archiver. | 42 | 43 | 44 | ### On `Inside the vault` 45 | 46 | | Key | Description | 47 | | ------------- | ------------------------------------------------------------------------------------ | 48 | | Backup folder | The folder which backups are stored. We can choose only the folder inside the vault. | 49 | 50 | ### On `Anywhere (Desktop only)` 51 | 52 | | Key | Description | 53 | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------- | 54 | | Backup folder (desktop) | The folder which backups are stored (if enabling `Use Desktop Mode`). We can choose any folder (Absolute path recommended). | 55 | 56 | 57 | ### On `S3 Compatible bucket` 58 | | Key | Description | 59 | | ----------------------- | ------------------------------------------------------------------------------------- | 60 | | Endpoint | The endpoint of the S3 bucket. | 61 | | AccessKey | The access key ID of the S3 bucket. | 62 | | SecretKey | The secret access key of the S3 bucket. | 63 | | Region | The region of the S3 bucket. | 64 | | Bucket | The name of the S3 bucket. | 65 | | Use Custom HTTP Handler | Use a custom HTTP handler for S3. This is useful for mobile devices services. | 66 | | Backup folder | The folder which backups are stored. We can choose only the folder inside the bucket. | 67 | 68 | #### Buttons 69 | - `Test`: Test the connection to the S3 bucket. 70 | - `Create Bucket`: Create a bucket in the S3 bucket. 71 | 72 | #### Tools 73 | Here are some tools to manage settings among your devices. 74 | 75 | | Key | Description | 76 | | ----------------------- | ------------------------------------------------------------------------------------- | 77 | | Passphrase | Passphrase for encrypting/decrypting the configuration. Please write this down as it will not be saved. | 78 | | Copy setting to another device via URI | When the button is clicked, the URI will be copied to the clipboard. Paste it to another device to copy the settings. | 79 | | Paste setting from another device | Paste the URI from another device to copy the settings, and click `Apply` button. | 80 | 81 | ## Misc 82 | 83 | ### Reset Backup Information 84 | If you want to make a full backup, you can reset the backup information. This will make all files to be backed up. 85 | 86 | ### Encryption 87 | If you configure the passphrase, the ZIP file will be encrypted by AES-256-CBC with the passphrase. 88 | 89 | >[!IMPORTANT] 90 | > Not compatible with the encrypted zip file. We have to decrypt the file by OpenSSL, without this plug-in. 91 | > Decryption command is `openssl enc -d -aes-256-cbc -in -out -k -pbkdf2 -md sha256`. 92 | 93 | 94 | 95 | 96 | ## What is `backupinfo.md`? 97 | 98 | This markdown file contains a list of file information. The list is stored as YAML. `backupinfo.md` is also stored in each Zip file. 99 | For the sake of simplicity, suppose we have three files, make a backup, change one of the files and make another backup. 100 | 101 | Then we get the following. 102 | 103 | ```yaml 104 | Untitled.md: 105 | digest: 452438bd53ea864cdf60269823ea8222366646c14f0f1cd450b5df4a74a7b19b 106 | filename: Untitled.md 107 | mtime: 1703656274225 108 | history: 109 | - zipName: 2023-12-28-41265.zip 110 | modified: 2023-12-27T05:51:14.225Z 111 | storedIn: 112 | Untitled 2.md: 113 | digest: 7241f90bf62d00fde6e0cf2ada1beb18776553ded5233f97f0be3f7066c83530 114 | filename: Untitled 2.md 115 | mtime: 1703656274225 116 | history: 117 | - zipName: 2023-12-28-41265.zip 118 | modified: 2023-12-27T05:51:14.225Z 119 | Untitled 1.md: 120 | digest: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 121 | filename: Untitled 1.md 122 | mtime: 1708498190402 123 | history: 124 | - zipName: 2023-12-28-41265.zip 125 | modified: 2023-12-27T05:51:14.225Z 126 | - zipName: 2024-2-21-56995.zip 127 | modified: 2024-02-21T06:49:50.402Z 128 | ``` 129 | 130 | The following entries are important. 131 | 132 | | key | value | 133 | | ------- | -------------------------------------------------------- | 134 | | digest | SHA-1 of the file. DZB detects all changes by this hash. | 135 | | history | Archived ZIP file name and Timestamp at the time. | 136 | 137 | Note: Modified time has been stored due to the lack of resolution of the ZIP file, but this is information for us. 138 | 139 | ### ZIP files 140 | We will get the following zip files. 141 | 142 | | 2023-12-28-41265.zip | 2024-2-21-56995.zip | 143 | | -------------------- | ------------------- | 144 | | Untitled.md | | 145 | | Untitled 1.md | | 146 | | Untitled 2.md | Untitled 1.md | 147 | | backupinfo.md | backupinfo.md | 148 | 149 | As the astute will have noticed, we can pick the ZIP that contains the file we want from only the latest one without any special tool! 150 | 151 | --- 152 | License: MIT 153 | -------------------------------------------------------------------------------- /RestoreFileInfo.svelte: -------------------------------------------------------------------------------- 1 | 44 | 45 |
46 | 47 | {filename} 48 | {#if isFolder} 49 | ({relatedFiles.length}) 50 | {/if} 51 | 52 | 53 | {#if timeStamps.length === 0} 54 | No Timestamp 55 | {:else} 56 | 71 | {/if} 72 | 73 | 74 | {#if isFolder} 75 | 81 | {/if} 82 | 83 | 84 |
85 | 86 | 116 | -------------------------------------------------------------------------------- /RestoreFiles.svelte: -------------------------------------------------------------------------------- 1 | 56 | 57 |
58 | {#if files} 59 | {#each files as file (file)} 60 | 61 | {/each} 62 | {/if} 63 |
64 | -------------------------------------------------------------------------------- /RestoreView.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AbstractInputSuggest, 3 | Modal, 4 | prepareFuzzySearch, 5 | Setting, 6 | type App, 7 | type SearchResult, 8 | type TextComponent, 9 | } from "obsidian"; 10 | import { mount, unmount } from "svelte"; 11 | import RestoreFilesComponent from "./RestoreFiles.svelte"; 12 | import type DiffZipBackupPlugin from "./main"; 13 | import { writable } from "svelte/store"; 14 | 15 | export const VIEW_TYPE_RESTORE = "diffzip-view-restore"; 16 | 17 | export type ListOperations = { 18 | expandFolder(name: string): void; 19 | expandAll(): void; 20 | remove(file: string): void; 21 | clearList(): void; 22 | fileSelected(file: string, timestamp: number): void; 23 | }; 24 | 25 | export const LATEST = Number.MAX_SAFE_INTEGER; 26 | 27 | export class RestoreDialog extends Modal { 28 | constructor( 29 | app: App, 30 | public plugin: DiffZipBackupPlugin 31 | ) { 32 | super(app); 33 | } 34 | 35 | component?: ReturnType; 36 | currentFile: string = ""; 37 | currentFiles: string[] = []; 38 | selectedTimestamps: { [file: string]: number } = {}; 39 | 40 | async onOpen() { 41 | const fileList = writable([]); 42 | const toc = await this.plugin.loadTOC(); 43 | this.currentFiles = []; 44 | fileList.set(this.currentFiles); 45 | 46 | const selectedTimestamp = writable(this.selectedTimestamps); 47 | selectedTimestamp.subscribe((value) => { 48 | this.selectedTimestamps = value; 49 | }); 50 | 51 | const containerEl = this.modalEl; 52 | containerEl.empty(); 53 | 54 | function getFiles() { 55 | const filesAll = Object.keys(toc); 56 | const dirs = [...new Set(filesAll.map((e) => (e.split("/").slice(0, -1).join("/") + "/").slice(0, -1)))] 57 | .map((e) => e + "/*") 58 | .map((e) => (e == "/*" ? "*" : e)); 59 | const files = [...dirs, ...filesAll].sort((a, b) => { 60 | const aDir = a.endsWith("*"); 61 | const bDir = b.endsWith("*"); 62 | if (aDir && !bDir) return -1; 63 | if (!aDir && bDir) return 1; 64 | return a.localeCompare(b); 65 | }); 66 | return files; 67 | } 68 | 69 | const headerEl = containerEl.createDiv({ cls: "diffzip-dialog-header" }); 70 | headerEl.createEl("h2", { text: "Restore" }); 71 | 72 | let currentFileInput: TextComponent | undefined; 73 | new Setting(headerEl) 74 | .setName("Add to candidates") 75 | .setDesc("Select the backup file to restore") 76 | .addText((text) => { 77 | text.setPlaceholder("folder/a.md") 78 | .setValue(this.currentFile) 79 | .onChange((value: string) => { 80 | this.currentFile = value; 81 | }); 82 | const p = new PopTextSuggest(this.app, text.inputEl, () => getFiles()); 83 | p.onSelect((value) => { 84 | text.setValue(value.source); 85 | p.close(); 86 | this.currentFile = value.source; 87 | }); 88 | currentFileInput = text; 89 | }) 90 | .addButton((b) => { 91 | b.setButtonText("Add").onClick(async () => { 92 | const file = this.currentFile; 93 | if (!file) return; 94 | if (!getFiles().includes(file)) return; 95 | this.currentFiles = [...new Set([...this.currentFiles, file])]; 96 | this.currentFile = ""; 97 | fileList.set(this.currentFiles); 98 | selectedTimestamp.update((selected) => { 99 | if (selected[file] === undefined) selected[file] = LATEST; 100 | return selected; 101 | }); 102 | currentFileInput?.setValue(""); 103 | }); 104 | }); 105 | 106 | const applyFiles = () => { 107 | fileList.set(this.currentFiles); 108 | selectedTimestamp.update((selected) => { 109 | Object.keys(selected).forEach((e) => { 110 | if (!this.currentFiles.includes(e)) delete selected[e]; 111 | }); 112 | return selected; 113 | }); 114 | }; 115 | 116 | const expandFolder = (name: string, preventRender = false) => { 117 | const folderPrefix = name.slice(0, -1); 118 | const files = getFiles().filter((e) => e.startsWith(folderPrefix)); 119 | this.currentFiles = [...new Set([...this.currentFiles, ...files])].filter((e) => e !== name); 120 | if (!preventRender) applyFiles(); 121 | }; 122 | 123 | new Setting(headerEl) 124 | .setName("") 125 | .addButton((b) => { 126 | b.setButtonText("Expand All Folder").onClick(async () => { 127 | const folders = this.currentFiles.filter((e) => e.endsWith("*")); 128 | for (const folder of folders) { 129 | expandFolder(folder, true); 130 | } 131 | applyFiles(); 132 | }); 133 | }) 134 | .addButton((b) => { 135 | b.setButtonText("Clear").onClick(async () => { 136 | this.currentFiles = []; 137 | applyFiles(); 138 | }); 139 | }); 140 | 141 | new Setting(headerEl) 142 | .addButton((b) => { 143 | b.setButtonText("Select All Latest").onClick(async () => { 144 | this.selectedTimestamps = {}; 145 | this.currentFiles.forEach((e) => { 146 | this.selectedTimestamps[e] = LATEST; 147 | }); 148 | selectedTimestamp.set(this.selectedTimestamps); 149 | }); 150 | }) 151 | .addButton((b) => { 152 | b.setButtonText("Clear").onClick(async () => { 153 | this.selectedTimestamps = {}; 154 | selectedTimestamp.set(this.selectedTimestamps); 155 | }); 156 | }); 157 | 158 | const filesEl = containerEl.createDiv(); 159 | filesEl.className = "diffzip-list"; 160 | 161 | fileList.subscribe((value) => { 162 | this.currentFiles = value; 163 | }); 164 | 165 | this.component = mount(RestoreFilesComponent, { 166 | target: filesEl, 167 | props: { 168 | plugin: this.plugin, 169 | toc, 170 | fileList, 171 | selectedTimestamp, 172 | }, 173 | }); 174 | 175 | const footerEl = containerEl.createDiv({ cls: "diffzip-dialog-footer" }); 176 | let option = ""; 177 | new Setting(footerEl).setName("Restore Options").addDropdown((d) => { 178 | d.addOptions({ 179 | new: "Only new", 180 | all: "All", 181 | "all-delete": "All and delete extra", 182 | }).onChange((value) => { 183 | option = value; 184 | }); 185 | }); 186 | 187 | let prefix = ""; 188 | new Setting(footerEl).setName("Additional prefix").addText((text) => { 189 | text.setPlaceholder("folder/") 190 | .setValue(prefix) 191 | .onChange((value: string) => { 192 | prefix = value; 193 | }); 194 | }); 195 | 196 | new Setting(footerEl) 197 | .setName("") 198 | .addButton((b) => { 199 | b.setButtonText("Restore") 200 | .onClick(async () => { 201 | this.close(); 202 | const onlyNew = option === "new"; 203 | const skipDeleted = option !== "all-delete"; 204 | const selected = this.selectedTimestamps; 205 | Object.keys(selected).forEach((e) => { 206 | if (!this.currentFiles.includes(e)) delete selected[e]; 207 | }); 208 | const allFiles = Object.keys(toc); 209 | const applyFiles = Object.fromEntries( 210 | Object.entries(selected) 211 | .map(([file, timestamp]) => 212 | file.endsWith("*") 213 | ? allFiles 214 | .filter((e) => e.startsWith(file.slice(0, -1))) 215 | .map((file) => [file, timestamp] as const) 216 | : ([[file, timestamp]] as const) 217 | ) 218 | .flat() 219 | ); 220 | this.plugin.restoreVault(onlyNew, skipDeleted, applyFiles, prefix); 221 | }) 222 | .setCta(); 223 | }) 224 | .addButton((b) => { 225 | b.setButtonText("Cancel").onClick(() => { 226 | this.close(); 227 | }); 228 | }); 229 | 230 | return await Promise.resolve(); 231 | } 232 | 233 | async onClose() { 234 | if (this.component) { 235 | unmount(this.component); 236 | this.component = undefined; 237 | } 238 | return await Promise.resolve(); 239 | } 240 | } 241 | 242 | type TextSearchResult = { result: SearchResult; source: string }; 243 | 244 | class PopTextSuggest extends AbstractInputSuggest { 245 | items: string[] = []; 246 | getItemFunc: () => string[]; 247 | 248 | constructor(app: App, inputEl: HTMLInputElement, getItemFunc: () => string[]) { 249 | super(app, inputEl); 250 | this.getItemFunc = getItemFunc; 251 | this.items = this.getItemFunc(); 252 | } 253 | 254 | open(): void { 255 | this.items = this.getItemFunc(); 256 | super.open(); 257 | } 258 | 259 | candidates: { result: SearchResult; source: string }[]; 260 | 261 | protected getSuggestions(query: string): TextSearchResult[] | Promise { 262 | const q = prepareFuzzySearch(query); 263 | const p = this.items.map((e) => ({ result: q(e), source: e })).filter((e) => e.result !== null) as { 264 | result: SearchResult; 265 | source: string; 266 | }[]; 267 | const pSorted = p.sort((a, b) => { 268 | const diff = b.result.score - a.result.score; 269 | if (diff != 0) return diff; 270 | return a.source.localeCompare(b.source); 271 | }); 272 | return pSorted; 273 | } 274 | 275 | renderSuggestion(value: TextSearchResult, el: HTMLElement): void { 276 | const source = [...value.source]; 277 | const highlighted = source.map(() => false); 278 | const matches = value.result.matches.reverse(); 279 | for (const [from, to] of matches) { 280 | for (let i = from; i < to; i++) { 281 | highlighted[i] = true; 282 | } 283 | } 284 | const div = el.createDiv(); 285 | let prevSpan: HTMLElement | null = null; 286 | let prevHighlighted = false; 287 | for (let i = 0; i < source.length; i++) { 288 | if (prevHighlighted != highlighted[i] || prevSpan == null) { 289 | prevSpan = div.createSpan(); 290 | prevHighlighted = highlighted[i]; 291 | if (prevHighlighted) { 292 | prevSpan.addClass("mod-highlight"); 293 | prevSpan.style.fontWeight = "bold"; 294 | } 295 | } 296 | const t = source[i]; 297 | prevSpan.appendText(t); 298 | } 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /dialog.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Modal, 3 | type App, 4 | Setting, 5 | type Plugin, 6 | type ButtonComponent, 7 | MarkdownRenderer, 8 | FuzzySuggestModal, 9 | } from "obsidian"; 10 | 11 | export class InputStringDialog extends Modal { 12 | result: string | false = false; 13 | onSubmit: (result: string | false) => void; 14 | title: string; 15 | key: string; 16 | placeholder: string; 17 | isManuallyClosed = false; 18 | isPassword = false; 19 | 20 | constructor( 21 | app: App, 22 | title: string, 23 | key: string, 24 | placeholder: string, 25 | isPassword: boolean, 26 | onSubmit: (result: string | false) => void 27 | ) { 28 | super(app); 29 | this.onSubmit = onSubmit; 30 | this.title = title; 31 | this.placeholder = placeholder; 32 | this.key = key; 33 | this.isPassword = isPassword; 34 | } 35 | 36 | onOpen() { 37 | const { contentEl } = this; 38 | this.titleEl.setText(this.title); 39 | const formEl = contentEl.createDiv(); 40 | new Setting(formEl) 41 | .setName(this.key) 42 | .setClass(this.isPassword ? "password-input" : "normal-input") 43 | .addText((text) => 44 | text.onChange((value) => { 45 | this.result = value; 46 | }) 47 | ); 48 | new Setting(formEl) 49 | .addButton((btn) => 50 | btn 51 | .setButtonText("Ok") 52 | .setCta() 53 | .onClick(() => { 54 | this.isManuallyClosed = true; 55 | this.close(); 56 | }) 57 | ) 58 | .addButton((btn) => 59 | btn 60 | .setButtonText("Cancel") 61 | .setCta() 62 | .onClick(() => { 63 | this.close(); 64 | }) 65 | ); 66 | } 67 | 68 | onClose() { 69 | super.onClose(); 70 | const { contentEl } = this; 71 | contentEl.empty(); 72 | if (this.isManuallyClosed) { 73 | this.onSubmit(this.result); 74 | } else { 75 | this.onSubmit(false); 76 | } 77 | } 78 | } 79 | 80 | export class MessageBox extends Modal { 81 | plugin: Plugin; 82 | title: string; 83 | contentMd: string; 84 | buttons: string[]; 85 | result: string | false = false; 86 | isManuallyClosed = false; 87 | defaultAction: string | undefined; 88 | 89 | defaultButtonComponent: ButtonComponent | undefined; 90 | wideButton: boolean; 91 | 92 | onSubmit: (result: string | false) => void; 93 | 94 | constructor( 95 | plugin: Plugin, 96 | title: string, 97 | contentMd: string, 98 | buttons: string[], 99 | defaultAction: (typeof buttons)[number], 100 | wideButton: boolean, 101 | onSubmit: (result: (typeof buttons)[number] | false) => void 102 | ) { 103 | super(plugin.app); 104 | this.plugin = plugin; 105 | this.title = title; 106 | this.contentMd = contentMd; 107 | this.buttons = buttons; 108 | this.onSubmit = onSubmit; 109 | this.defaultAction = defaultAction; 110 | this.wideButton = wideButton; 111 | } 112 | 113 | onOpen() { 114 | const { contentEl } = this; 115 | this.titleEl.setText(this.title); 116 | const div = contentEl.createDiv(); 117 | div.style.userSelect = "text"; 118 | void MarkdownRenderer.render(this.plugin.app, this.contentMd, div, "/", this.plugin); 119 | const buttonSetting = new Setting(contentEl); 120 | 121 | buttonSetting.infoEl.style.display = "none"; 122 | buttonSetting.controlEl.style.flexWrap = "wrap"; 123 | if (this.wideButton) { 124 | buttonSetting.controlEl.style.flexDirection = "column"; 125 | buttonSetting.controlEl.style.alignItems = "center"; 126 | buttonSetting.controlEl.style.justifyContent = "center"; 127 | buttonSetting.controlEl.style.flexGrow = "1"; 128 | } 129 | 130 | for (const button of this.buttons) { 131 | buttonSetting.addButton((btn) => { 132 | btn.setButtonText(button).onClick(() => { 133 | this.isManuallyClosed = true; 134 | this.result = button; 135 | this.close(); 136 | }); 137 | if (button == this.defaultAction) { 138 | this.defaultButtonComponent = btn; 139 | btn.setCta(); 140 | } 141 | if (this.wideButton) { 142 | btn.buttonEl.style.flexGrow = "1"; 143 | btn.buttonEl.style.width = "100%"; 144 | } 145 | return btn; 146 | }); 147 | } 148 | } 149 | 150 | onClose() { 151 | super.onClose(); 152 | const { contentEl } = this; 153 | contentEl.empty(); 154 | if (this.isManuallyClosed) { 155 | this.onSubmit(this.result); 156 | } else { 157 | this.onSubmit(false); 158 | } 159 | } 160 | } 161 | 162 | export function confirmWithMessage( 163 | plugin: Plugin, 164 | title: string, 165 | contentMd: string, 166 | buttons: string[], 167 | defaultAction: (typeof buttons)[number] 168 | ): Promise<(typeof buttons)[number] | false> { 169 | return new Promise((res) => { 170 | const dialog = new MessageBox(plugin, title, contentMd, buttons, defaultAction, false, (result) => res(result)); 171 | dialog.open(); 172 | }); 173 | } 174 | export class PopOverSelectString extends FuzzySuggestModal { 175 | callback?: (e: string) => void; 176 | getItemsFun: () => string[]; 177 | 178 | constructor( 179 | app: App, 180 | note: string, 181 | placeholder: string | null, 182 | getItemsFun: () => string[], 183 | callback: (e: string) => void 184 | ) { 185 | super(app); 186 | this.setPlaceholder((placeholder ?? "y/n) ") + note); 187 | this.getItemsFun = getItemsFun; 188 | this.callback = callback; 189 | } 190 | 191 | getItems(): string[] { 192 | return this.getItemsFun(); 193 | } 194 | 195 | getItemText(item: string): string { 196 | return item; 197 | } 198 | 199 | onChooseItem(item: string, evt: MouseEvent | KeyboardEvent): void { 200 | this.callback?.(item); 201 | this.callback = undefined; 202 | } 203 | 204 | onClose(): void { 205 | setTimeout(() => { 206 | if (this.callback != undefined) { 207 | this.callback(""); 208 | } 209 | }, 100); 210 | } 211 | } 212 | 213 | export const askSelectString = (app: App, message: string, items: string[]): Promise => { 214 | return new Promise((res) => { 215 | const popOver = new PopOverSelectString(app, message, "", () => items, res); 216 | popOver.open(); 217 | }); 218 | }; 219 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | import sveltePlugin from "esbuild-svelte"; 5 | import { sveltePreprocess } from "svelte-preprocess"; 6 | const banner = 7 | `/* 8 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 9 | if you want to view the source, please visit the github repository of this plugin 10 | */ 11 | `; 12 | 13 | const prod = (process.argv[2] === "production"); 14 | 15 | const context = await esbuild.context({ 16 | banner: { 17 | js: banner, 18 | }, 19 | entryPoints: ["main.ts"], 20 | bundle: true, 21 | external: [ 22 | "obsidian", 23 | "electron", 24 | "@codemirror/autocomplete", 25 | "@codemirror/collab", 26 | "@codemirror/commands", 27 | "@codemirror/language", 28 | "@codemirror/lint", 29 | "@codemirror/search", 30 | "@codemirror/state", 31 | "@codemirror/view", 32 | "@lezer/common", 33 | "@lezer/highlight", 34 | "@lezer/lr", 35 | ...builtins], 36 | format: "cjs", 37 | target: "es2018", 38 | logLevel: "info", 39 | sourcemap: prod ? false : "inline", 40 | treeShaking: true, 41 | outfile: "main.js", 42 | plugins: [ 43 | sveltePlugin({ 44 | preprocess: sveltePreprocess({ 45 | preserveComments: false, 46 | compilerOptions: { 47 | removeComments: true, 48 | }, 49 | }), 50 | compilerOptions: { 51 | css: "injected", 52 | preserveComments: false, 53 | preserveWhitespace: false, 54 | }, 55 | }), 56 | ], 57 | }); 58 | 59 | if (prod) { 60 | await context.rebuild(); 61 | process.exit(0); 62 | } else { 63 | await context.watch(); 64 | } 65 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Plugin, parseYaml, stringifyYaml } from "obsidian"; 2 | import * as fflate from "fflate"; 3 | import { getStorage, getStorageInstance, getStorageType, type StorageAccessor } from "./storage"; 4 | import { RestoreDialog } from "./RestoreView"; 5 | import { confirmWithMessage } from "./dialog"; 6 | import { Archiver, Extractor } from "./Archive"; 7 | import { computeDigest, pieces } from "./util"; 8 | import { 9 | AutoBackupType, 10 | DEFAULT_SETTINGS, 11 | InfoFile, 12 | type DiffZipBackupSettings, 13 | type FileInfo, 14 | type FileInfos, 15 | type NoticeWithTimer, 16 | } from "./types"; 17 | import { DiffZipSettingTab } from "./DiffZipSettingTab"; 18 | import { askSelectString } from "dialog"; 19 | 20 | export default class DiffZipBackupPlugin extends Plugin { 21 | settings: DiffZipBackupSettings; 22 | 23 | get isMobile(): boolean { 24 | // @ts-ignore 25 | return this.app.isMobile; 26 | } 27 | get isDesktopMode(): boolean { 28 | return this.settings.desktopFolderEnabled && !this.isMobile; 29 | } 30 | 31 | get backupFolder(): string { 32 | if (this.settings.bucketEnabled) return this.settings.backupFolderBucket; 33 | return this.isDesktopMode ? this.settings.BackupFolderDesktop : this.settings.backupFolderMobile; 34 | } 35 | 36 | _backups: StorageAccessor; 37 | get backups(): StorageAccessor { 38 | const type = getStorageType(this); 39 | if (!this._backups || this._backups.type != type) { 40 | this._backups = getStorage(this); 41 | } 42 | return this._backups; 43 | } 44 | _vaultAccess: StorageAccessor; 45 | get vaultAccess(): StorageAccessor { 46 | const type = this.settings.includeHiddenFolder ? "direct" : "normal"; 47 | if (!this._vaultAccess || this._vaultAccess.type != type) { 48 | this._vaultAccess = getStorageInstance(type, this, undefined, true); 49 | } 50 | return this._vaultAccess; 51 | } 52 | 53 | get sep(): string { 54 | //@ts-ignore 55 | return this.isDesktopMode ? this.app.vault.adapter.path.sep : "/"; 56 | } 57 | 58 | messages = {} as Record; 59 | 60 | logMessage(message: string, key?: string) { 61 | this.logWrite(message, key); 62 | if (!key) { 63 | new Notice(message, 3000); 64 | return; 65 | } 66 | let n: NoticeWithTimer | undefined = undefined; 67 | if (key in this.messages) { 68 | n = this.messages[key]; 69 | clearTimeout(n.timer); 70 | if (!n.notice.noticeEl.isShown()) { 71 | delete this.messages[key]; 72 | } else { 73 | n.notice.setMessage(message); 74 | } 75 | } 76 | if (!n || !(key in this.messages)) { 77 | n = { 78 | notice: new Notice(message, 0), 79 | }; 80 | } 81 | n.timer = setTimeout(() => { 82 | n?.notice?.hide(); 83 | }, 5000); 84 | this.messages[key] = n; 85 | } 86 | logWrite(message: string, key?: string) { 87 | const dt = new Date().toLocaleString(); 88 | console.log(`${dt}\t${message}`); 89 | } 90 | 91 | async getFiles(path: string, ignoreList: string[]) { 92 | const w = await this.app.vault.adapter.list(path); 93 | let files = [...w.files.filter((e) => !ignoreList.some((ee) => e.endsWith(ee)))]; 94 | L1: for (const v of w.folders) { 95 | for (const ignore of ignoreList) { 96 | if (v.endsWith(ignore)) { 97 | continue L1; 98 | } 99 | } 100 | // files = files.concat([v]); 101 | files = files.concat(await this.getFiles(v, ignoreList)); 102 | } 103 | return files; 104 | } 105 | 106 | async loadTOC() { 107 | let toc = {} as FileInfos; 108 | const tocFilePath = this.backups.normalizePath(`${this.backupFolder}${this.sep}${InfoFile}`); 109 | const tocExist = await this.backups.isFileExists(tocFilePath); 110 | if (tocExist) { 111 | this.logWrite(`Loading Backup information`, "proc-index"); 112 | try { 113 | const tocBin = await this.backups.readTOC(tocFilePath); 114 | if (tocBin == null || tocBin === false) { 115 | this.logMessage(`LOAD ERROR: Could not read Backup information`, "proc-index"); 116 | return {}; 117 | } 118 | const tocStr = new TextDecoder().decode(tocBin); 119 | toc = parseYaml(tocStr.replace(/^```$/gm, "")); 120 | if (toc == null) { 121 | this.logMessage(`PARSE ERROR: Could not parse Backup information`, "proc-index"); 122 | toc = {}; 123 | } else { 124 | this.logWrite(`Backup information has been loaded`, "proc-index"); 125 | } 126 | } catch (ex) { 127 | this.logMessage(`Something went wrong while parsing Backup information`, "proc-index"); 128 | console.warn(ex); 129 | toc = {}; 130 | } 131 | } else { 132 | this.logMessage(`Backup information looks missing`, "proc-index"); 133 | } 134 | return toc; 135 | } 136 | 137 | async getAllFiles() { 138 | const ignores = [ 139 | "node_modules", 140 | ".git", 141 | this.app.vault.configDir + "/trash", 142 | this.app.vault.configDir + "/workspace.json", 143 | this.app.vault.configDir + "/workspace-mobile.json", 144 | ]; 145 | if (this.settings.includeHiddenFolder) { 146 | return (await this.getFiles("", ignores)).filter((e) => !e.startsWith(".trash/")); 147 | } 148 | return this.app.vault.getFiles().map((e) => e.path); 149 | } 150 | 151 | async createZip(verbosity: boolean, skippableFiles: string[] = [], onlyNew = false, skipDeleted: boolean = false) { 152 | const log = verbosity 153 | ? (msg: string, key?: string) => this.logWrite(msg, key) 154 | : (msg: string, key?: string) => this.logMessage(msg, key); 155 | 156 | const allFiles = await this.getAllFiles(); 157 | const toc = await this.loadTOC(); 158 | const today = new Date(); 159 | const secondsInDay = ~~(today.getTime() / 1000 - today.getTimezoneOffset() * 60) % 86400; 160 | 161 | const newFileName = `${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate()}-${secondsInDay}.zip`; 162 | 163 | // Find missing files 164 | let missingFiles = 0; 165 | for (const [filename, fileInfo] of Object.entries(toc)) { 166 | if (fileInfo.missing) continue; 167 | if (!(await this.vaultAccess.isFileExists(this.vaultAccess.normalizePath(filename)))) { 168 | if (skipDeleted) continue; 169 | fileInfo.missing = true; 170 | fileInfo.digest = ""; 171 | fileInfo.mtime = today.getTime(); 172 | fileInfo.processed = today.getTime(); 173 | log(`File ${filename} is missing`); 174 | fileInfo.history = [ 175 | ...fileInfo.history, 176 | { 177 | zipName: newFileName, 178 | modified: today.toISOString(), 179 | missing: true, 180 | processed: today.getTime(), 181 | digest: "", 182 | }, 183 | ]; 184 | log(`History of ${filename} has been updated (Missing)`); 185 | missingFiles++; 186 | } 187 | } 188 | 189 | const zip = new Archiver(); 190 | 191 | const normalFiles = allFiles 192 | .filter( 193 | (e) => 194 | !e.startsWith(this.backupFolder + this.sep) && !e.startsWith(this.settings.restoreFolder + this.sep) 195 | ) 196 | .filter((e) => skippableFiles.indexOf(e) == -1); 197 | let processed = 0; 198 | const processedFiles = [] as string[]; 199 | let zipped = 0; 200 | for (const path of normalFiles) { 201 | processedFiles.push(path); 202 | processed++; 203 | if (processed % 10 == 0) 204 | this.logMessage( 205 | `Backup processing ${processed}/${normalFiles.length} ${verbosity ? `\n${path}` : ""}`, 206 | "proc-zip-process" 207 | ); 208 | // Retrieve the file information 209 | const stat = await this.vaultAccess.stat(path); 210 | if (!stat) { 211 | this.logMessage(`Archiving: Could not read stat ${path}`); 212 | continue; 213 | } 214 | // Check the file is in the skippable list 215 | if (onlyNew && path in toc) { 216 | const entry = toc[path]; 217 | const mtime = new Date(stat.mtime).getTime(); 218 | if (mtime <= entry.mtime) { 219 | this.logWrite(`${path} older than the last backup, skipping`); 220 | continue; 221 | } 222 | } 223 | // Read the file content 224 | const content = await this.vaultAccess.readBinary(path); 225 | if (!content) { 226 | this.logMessage(`Archiving: Could not read ${path}`); 227 | continue; 228 | } 229 | 230 | // Check the file actually modified. 231 | const f = new Uint8Array(content); 232 | const digest = await computeDigest(f); 233 | 234 | if (path in toc) { 235 | const entry = toc[path]; 236 | if (entry.digest == digest) { 237 | this.logWrite(`${path} Not changed`); 238 | continue; 239 | } 240 | } 241 | zipped++; 242 | 243 | // Update the file information 244 | toc[path] = { 245 | digest, 246 | filename: path, 247 | mtime: stat.mtime, 248 | processed: today.getTime(), 249 | history: [ 250 | ...(toc[path]?.history ?? []), 251 | { 252 | zipName: newFileName, 253 | modified: new Date(stat.mtime).toISOString(), 254 | processed: today.getTime(), 255 | digest, 256 | }, 257 | ], 258 | }; 259 | this.logMessage(`Archiving: ${path} ${zipped}/${normalFiles.length}`, "proc-zip-archive"); 260 | zip.addFile(f, path, { mtime: stat.mtime }); 261 | if (this.settings.maxFilesInZip > 0 && zipped >= this.settings.maxFilesInZip) { 262 | this.logMessage( 263 | `Max files in a single ZIP has been reached. The rest of the files will be archived in the next process`, 264 | "finish" 265 | ); 266 | break; 267 | } 268 | } 269 | this.logMessage( 270 | `All ${processed} files have been scanned, ${zipped} files are now compressing. please wait for a while`, 271 | "proc-zip-process" 272 | ); 273 | if (zipped == 0 && missingFiles == 0) { 274 | this.logMessage(`Nothing has been changed! Generating ZIP has been skipped.`); 275 | return; 276 | } 277 | const tocTimeStamp = new Date().getTime(); 278 | zip.addTextFile(`\`\`\`\n${stringifyYaml(toc)}\n\`\`\`\n`, InfoFile, { mtime: tocTimeStamp }); 279 | try { 280 | const buf = await zip.finalize(); 281 | // Writing a large file can cause the crash of Obsidian, and very heavy to synchronise. 282 | // Hence, we have to split the file into a smaller size. 283 | const step = 284 | this.settings.maxSize / 1 == 0 ? buf.byteLength + 1 : (this.settings.maxSize / 1) * 1024 * 1024; 285 | let pieceCount = 0; 286 | // If the file size is smaller than the step, it will be a single file. 287 | // Otherwise, it will be split into multiple files. (start from 001) 288 | if (buf.byteLength > step) pieceCount = 1; 289 | const chunks = pieces(buf, step); 290 | for (const chunk of chunks) { 291 | const outZipFile = this.backups.normalizePath( 292 | `${this.backupFolder}${this.sep}${newFileName}${pieceCount == 0 ? "" : "." + `00${pieceCount}`.slice(-3)}` 293 | ); 294 | pieceCount++; 295 | this.logMessage(`Creating ${outZipFile}...`, `proc-zip-process-write-${pieceCount}`); 296 | const e = await this.backups.writeBinary(outZipFile, chunk); 297 | if (!e) { 298 | throw new Error(`Creating ${outZipFile} has been failed!`); 299 | } 300 | this.logMessage(`Creating ${outZipFile}...`, `proc-zip-process-write-${pieceCount}`); 301 | } 302 | 303 | const tocFilePath = this.backups.normalizePath(`${this.backupFolder}${this.sep}${InfoFile}`); 304 | 305 | // Update TOC 306 | if ( 307 | !(await this.backups.writeTOC( 308 | tocFilePath, 309 | new TextEncoder().encode(`\`\`\`\n${stringifyYaml(toc)}\n\`\`\`\n`) 310 | )) 311 | ) { 312 | throw new Error(`Updating TOC has been failed!`); 313 | } 314 | log(`Backup information has been updated`); 315 | if ( 316 | this.settings.maxFilesInZip > 0 && 317 | zipped >= this.settings.maxFilesInZip && 318 | this.settings.performNextBackupOnMaxFiles 319 | ) { 320 | setTimeout(() => { 321 | this.createZip(verbosity, [...skippableFiles, ...processedFiles], onlyNew, skipDeleted); 322 | }, 10); 323 | } else { 324 | this.logMessage( 325 | `All ${processed} files have been processed, ${zipped} files have been zipped.`, 326 | "proc-zip-process" 327 | ); 328 | } 329 | // } else { 330 | // this.logMessage(`Backup has been aborted \n${processed} files, ${zipped} zip files`, "proc-zip-process"); 331 | // } 332 | } catch (e) { 333 | this.logMessage( 334 | `Something get wrong while processing ${processed} files, ${zipped} zip files`, 335 | "proc-zip-process" 336 | ); 337 | this.logWrite(e); 338 | } 339 | } 340 | 341 | async extract(zipFile: string, extractFiles: string[]): Promise; 342 | async extract(zipFile: string, extractFiles: string, restoreAs: string): Promise; 343 | async extract(zipFile: string, extractFiles: string[], restoreAs: undefined, restorePrefix: string): Promise; 344 | async extract( 345 | zipFile: string, 346 | extractFiles: string | string[], 347 | restoreAs: string | undefined = undefined, 348 | restorePrefix: string = "" 349 | ): Promise { 350 | const hasMultipleSupplied = Array.isArray(extractFiles); 351 | const zipPath = this.backups.normalizePath(`${this.backupFolder}${this.sep}${zipFile}`); 352 | const zipF = await this.backups.isExists(zipPath); 353 | let files = [] as string[]; 354 | if (zipF) { 355 | files = [zipPath]; 356 | } else { 357 | let hasNext = true; 358 | let counter = 0; 359 | do { 360 | counter++; 361 | const partialZipPath = zipPath + "." + `00${counter}`.slice(-3); 362 | if (await this.backups.isExists(partialZipPath)) { 363 | files.push(partialZipPath); 364 | } else { 365 | hasNext = false; 366 | } 367 | } while (hasNext); 368 | } 369 | if (files.length == 0) { 370 | this.logMessage("Archived ZIP files were not found!"); 371 | } 372 | const restored = [] as string[]; 373 | 374 | const extractor = new Extractor( 375 | (file: fflate.UnzipFile) => { 376 | if (hasMultipleSupplied) { 377 | return extractFiles.indexOf(file.name) !== -1; 378 | } 379 | return file.name === extractFiles; 380 | }, 381 | async (file: string, dat: Uint8Array) => { 382 | const fileName = restoreAs ?? file; 383 | const restoreTo = hasMultipleSupplied ? `${restorePrefix}${fileName}` : fileName; 384 | if (await this.vaultAccess.writeBinary(restoreTo, dat)) { 385 | restored.push(restoreTo); 386 | const files = restored.slice(-5).join("\n"); 387 | this.logMessage(`${restored.length} files have been restored! \n${files}\n...`, "proc-zip-extract"); 388 | } else { 389 | this.logMessage(`Creating or Overwriting ${file} has been failed!`); 390 | } 391 | } 392 | ); 393 | 394 | const size = 1024 * 1024; 395 | for (const file of files) { 396 | this.logMessage(`Processing ${file}...`, "proc-zip-export-processing"); 397 | const binary = await this.backups.readBinary(file); 398 | if (binary == null || binary === false) { 399 | this.logMessage(`Could not read ${file}`); 400 | return; 401 | } 402 | const chunks = pieces(new Uint8Array(binary), size); 403 | for await (const chunk of chunks) { 404 | extractor.addZippedContent(chunk); 405 | } 406 | } 407 | } 408 | 409 | async selectAndRestore() { 410 | const files = await this.loadTOC(); 411 | const filenames = Object.entries(files) 412 | .sort((a, b) => b[1].mtime - a[1].mtime) 413 | .map((e) => e[0]); 414 | if (filenames.length == 0) { 415 | return; 416 | } 417 | const selected = await askSelectString(this.app, "Select file", filenames); 418 | if (!selected) { 419 | return; 420 | } 421 | const revisions = files[selected].history; 422 | const d = `\u{2063}`; 423 | const revisionList = revisions.map((e) => `${e.zipName}${d} (${e.modified})`).reverse(); 424 | const selectedTimestamp = await askSelectString(this.app, "Select file", revisionList); 425 | if (!selectedTimestamp) { 426 | return; 427 | } 428 | const [filename] = selectedTimestamp.split(d); 429 | const suffix = filename.replace(".zip", ""); 430 | // No cares about without extension 431 | const extArr = selected.split("."); 432 | const ext = extArr.pop(); 433 | const selectedWithoutExt = extArr.join("."); 434 | const RESTORE_OVERWRITE = "Original place and okay to overwrite"; 435 | const RESTORE_TO_RESTORE_FOLDER = "Under the restore folder"; 436 | const RESTORE_WITH_SUFFIX = "Original place but with ZIP name suffix"; 437 | const restoreMethods = [RESTORE_TO_RESTORE_FOLDER, RESTORE_OVERWRITE, RESTORE_WITH_SUFFIX]; 438 | const howToRestore = await askSelectString(this.app, "Where to restore?", restoreMethods); 439 | const restoreAs = 440 | howToRestore == RESTORE_OVERWRITE 441 | ? selected 442 | : howToRestore == RESTORE_TO_RESTORE_FOLDER 443 | ? this.vaultAccess.normalizePath(`${this.settings.restoreFolder}${this.sep}${selected}`) 444 | : howToRestore == RESTORE_WITH_SUFFIX 445 | ? `${selectedWithoutExt}-${suffix}.${ext}` 446 | : ""; 447 | if (!restoreAs) { 448 | return; 449 | } 450 | await this.extract(filename, selected, restoreAs); 451 | } 452 | 453 | async pickRevisions(files: FileInfos, prefix = ""): Promise { 454 | const BACK = "[..]"; 455 | const timestamps = new Set(); 456 | const all = Object.entries(files).filter((e) => e[0].startsWith(prefix)); 457 | for (const f of all) { 458 | f[1].history.map((e) => e.modified).map((e) => timestamps.add(e)); 459 | } 460 | const modifiedList = [...timestamps].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).reverse(); 461 | modifiedList.unshift(BACK); 462 | const selected = await askSelectString(this.app, "Until?", modifiedList); 463 | if (!selected) { 464 | return ""; 465 | } 466 | return selected; 467 | } 468 | async selectAndRestoreFolder(filesSrc?: FileInfos, prefix = "") { 469 | if (!filesSrc) filesSrc = await this.loadTOC(); 470 | const files = JSON.parse(JSON.stringify({ ...filesSrc })) as typeof filesSrc; 471 | const level = prefix.split("/").filter((e) => !!e).length + 1; 472 | const filenamesAll = Object.entries(files) 473 | .sort((a, b) => b[1].mtime - a[1].mtime) 474 | .map((e) => e[0]); 475 | const filenamesFiltered = filenamesAll.filter((e) => e.startsWith(prefix)); 476 | const filenamesA = filenamesFiltered 477 | .map((e) => { 478 | const paths = e.split("/"); 479 | const name = paths.splice(0, level).join("/"); 480 | if (paths.length == 0 && name) return name; 481 | return `${name}/`; 482 | }) 483 | .sort((a, b) => { 484 | const isDirA = a.endsWith("/"); 485 | const isDirB = b.endsWith("/"); 486 | if (isDirA && !isDirB) return -1; 487 | if (!isDirA && isDirB) return 1; 488 | if (isDirA && isDirB) return a.localeCompare(b); 489 | return 0; 490 | }); 491 | 492 | const filenames = [...new Set(filenamesA)]; 493 | if (filenames.length == 0) { 494 | return; 495 | } 496 | 497 | const BACK = "[..]"; 498 | const ALL = "[ALL]"; 499 | 500 | filenames.unshift(ALL); 501 | filenames.unshift(BACK); 502 | 503 | const selected = await askSelectString(this.app, "Select file", filenames); 504 | if (!selected) { 505 | return; 506 | } 507 | if (selected == BACK) { 508 | const p = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; 509 | const parent = p.split("/").slice(0, -1).join("/"); 510 | await this.selectAndRestoreFolder(filesSrc, parent); 511 | return; 512 | } 513 | if (selected == ALL) { 514 | // Collect all files and timings 515 | const selectedThreshold = await this.pickRevisions(files, prefix); 516 | if (!selectedThreshold) { 517 | return; 518 | } 519 | if (selectedThreshold == BACK) { 520 | await this.selectAndRestoreFolder(filesSrc, prefix); 521 | return; 522 | } 523 | const allFiles = Object.entries(files).filter((e) => e[0].startsWith(prefix)); 524 | const maxDate = new Date(selectedThreshold).getTime(); 525 | const fileMap = new Map(); 526 | for (const [key, files] of allFiles) { 527 | for (const fileInfo of files.history) { 528 | //keep only the latest one 529 | const fileModified = new Date(fileInfo.modified).getTime(); 530 | if (fileModified > maxDate) continue; 531 | const info = fileMap.get(key); 532 | if (!info) { 533 | fileMap.set(key, fileInfo); 534 | } else { 535 | if (new Date(info.modified).getTime() < fileModified) { 536 | fileMap.set(key, fileInfo); 537 | } 538 | } 539 | } 540 | } 541 | const zipMap = new Map(); 542 | for (const [filename, fileInfo] of fileMap) { 543 | const path = fileInfo.zipName; 544 | const arr = zipMap.get(path) ?? []; 545 | arr.push(filename); 546 | zipMap.set(path, arr); 547 | } 548 | // const fileMap = new Map(); 549 | // for (const [zipName, fileInfo] of zipMap) { 550 | // const path = fileInfo.zipName; 551 | // fileMap.set(path, zipName); 552 | // } 553 | const zipList = [...zipMap.entries()].sort((a, b) => a[0].localeCompare(b[0])); 554 | const filesCount = zipList.reduce((a, b) => a + b[1].length, 0); 555 | if ( 556 | (await askSelectString( 557 | this.app, 558 | `Are you sure to restore(Overwrite) ${filesCount} files from ${zipList.length} ZIPs`, 559 | ["Y", "N"] 560 | )) != "Y" 561 | ) { 562 | this.logMessage(`Cancelled`); 563 | return; 564 | } 565 | this.logMessage(`Extract ${zipList.length} ZIPs`); 566 | let i = 0; 567 | for (const [zipName, files] of zipList) { 568 | i++; 569 | this.logMessage(`Extract ${files.length} files from ${zipName} (${i}/${zipList.length})`); 570 | await this.extract(zipName, files); 571 | } 572 | // console.dir(zipMap); 573 | 574 | return; 575 | } 576 | if (selected.endsWith("/")) { 577 | await this.selectAndRestoreFolder(filesSrc, selected); 578 | return; 579 | } 580 | const revisions = files[selected].history; 581 | const d = `\u{2063}`; 582 | const revisionList = revisions.map((e) => `${e.zipName}${d} (${e.modified})`).reverse(); 583 | revisionList.unshift(BACK); 584 | const selectedTimestamp = await askSelectString(this.app, "Select file", revisionList); 585 | if (!selectedTimestamp) { 586 | return; 587 | } 588 | if (selectedTimestamp == BACK) { 589 | await this.selectAndRestoreFolder(filesSrc, prefix); 590 | return; 591 | } 592 | const [filename] = selectedTimestamp.split(d); 593 | const suffix = filename.replace(".zip", ""); 594 | // No cares about without extension 595 | const extArr = selected.split("."); 596 | const ext = extArr.pop(); 597 | const selectedWithoutExt = extArr.join("."); 598 | const RESTORE_OVERWRITE = "Original place and okay to overwrite"; 599 | const RESTORE_TO_RESTORE_FOLDER = "Under the restore folder"; 600 | const RESTORE_WITH_SUFFIX = "Original place but with ZIP name suffix"; 601 | const restoreMethods = [RESTORE_TO_RESTORE_FOLDER, RESTORE_OVERWRITE, RESTORE_WITH_SUFFIX]; 602 | const howToRestore = await askSelectString(this.app, "Where to restore?", restoreMethods); 603 | const restoreAs = 604 | howToRestore == RESTORE_OVERWRITE 605 | ? selected 606 | : howToRestore == RESTORE_TO_RESTORE_FOLDER 607 | ? this.vaultAccess.normalizePath(`${this.settings.restoreFolder}${this.sep}${selected}`) 608 | : howToRestore == RESTORE_WITH_SUFFIX 609 | ? `${selectedWithoutExt}-${suffix}.${ext}` 610 | : ""; 611 | if (!restoreAs) { 612 | return; 613 | } 614 | await this.extract(filename, selected, restoreAs); 615 | } 616 | // _debugDialogue?: RestoreDialog; 617 | async onLayoutReady() { 618 | // if (this._debugDialogue) { 619 | // this._debugDialogue.close(); 620 | // this._debugDialogue = undefined; 621 | // } 622 | if (this.settings.startBackupAtLaunch) { 623 | const onlyNew = 624 | this.settings.startBackupAtLaunchType == AutoBackupType.ONLY_NEW || 625 | this.settings.startBackupAtLaunchType == AutoBackupType.ONLY_NEW_AND_EXISTING; 626 | const skipDeleted = this.settings.startBackupAtLaunchType == AutoBackupType.ONLY_NEW_AND_EXISTING; 627 | this.createZip(false, [], onlyNew, skipDeleted); 628 | } 629 | } 630 | // onunload(): void { 631 | // this._debugDialogue?.close(); 632 | // } 633 | 634 | async restoreVault( 635 | onlyNew = true, 636 | deleteMissing: boolean = false, 637 | fileFilter: Record | undefined = undefined, 638 | prefix: string = "" 639 | ) { 640 | this.logMessage(`Checking backup information...`); 641 | const files = await this.loadTOC(); 642 | // const latestZipMap = new Map(); 643 | const zipFileMap = new Map(); 644 | const thisPluginDir = this.manifest.dir; 645 | const deletingFiles = [] as string[]; 646 | let processFileCount = 0; 647 | for (const [filename, fileInfo] of Object.entries(files)) { 648 | if (fileFilter) { 649 | const matched = Object.keys(fileFilter) 650 | .filter((e) => (e.endsWith("*") ? filename.startsWith(e.slice(0, -1)) : e == filename)) 651 | .sort((a, b) => b.length - a.length); 652 | if (matched.length == 0) { 653 | this.logWrite(`${filename}: is not matched with supplied filter. Skipping...`); 654 | continue; 655 | } 656 | const matchedFilter = matched[0]; 657 | // remove history after the filter 658 | fileInfo.history = fileInfo.history.filter( 659 | (e) => new Date(e.modified).getTime() <= fileFilter[matchedFilter] 660 | ); 661 | } 662 | if (thisPluginDir && fileInfo.filename.startsWith(thisPluginDir)) { 663 | this.logWrite(`${filename} is a plugin file. Skipping on vault restoration`); 664 | continue; 665 | } 666 | const history = fileInfo.history; 667 | if (history.length == 0) { 668 | this.logWrite(`${filename}: has no history. Skipping...`); 669 | continue; 670 | } 671 | history.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime()); 672 | const latest = history[0]; 673 | const zipName = latest.zipName; 674 | const localFileName = this.vaultAccess.normalizePath(`${prefix}${filename}`); 675 | const localStat = await this.vaultAccess.stat(localFileName); 676 | if (localStat) { 677 | const content = await this.vaultAccess.readBinary(localFileName); 678 | if (!content) { 679 | this.logWrite(`${filename}: has been failed to read`); 680 | continue; 681 | } 682 | const localDigest = await computeDigest(new Uint8Array(content)); 683 | if (localDigest == latest?.digest) { 684 | this.logWrite(`${filename}: is as same as the backup. Skipping...`); 685 | continue; 686 | } 687 | if (fileInfo.missing) { 688 | if (!deleteMissing) { 689 | this.logWrite(`${filename}: is marked as missing, but existing in the vault. Skipping...`); 690 | continue; 691 | } else { 692 | // this.logWrite(`${filename}: is marked as missing. Deleting...`); 693 | deletingFiles.push(filename); 694 | //TODO: Delete the file 695 | } 696 | } 697 | const localMtime = localStat.mtime; 698 | const remoteMtime = new Date(latest.modified).getTime(); 699 | if (onlyNew && localMtime >= remoteMtime) { 700 | this.logWrite(`${filename}: Ours is newer than the backup. Skipping...`); 701 | continue; 702 | } 703 | } else { 704 | if (fileInfo.missing) { 705 | this.logWrite(`${filename}: is missing and not found in the vault. Skipping...`); 706 | continue; 707 | } 708 | } 709 | this.logWrite(`${filename}: will be restored from ${zipName}`); 710 | if (!zipFileMap.has(zipName)) { 711 | zipFileMap.set(zipName, []); 712 | } 713 | zipFileMap.get(zipName)?.push(filename); 714 | processFileCount++; 715 | 716 | // latestZipMap.set(filename, zipName); 717 | } 718 | if (processFileCount == 0 && deletingFiles.length == 0) { 719 | this.logMessage(`Nothing to restore`); 720 | return; 721 | } 722 | const detailFiles = `
723 | 724 | ${[...zipFileMap.entries()] 725 | .map((e) => `${e[1].map((ee) => `- ${ee} (${e[0]})`).join("\n")}\n`) 726 | .sort((a, b) => a.localeCompare(b)) 727 | .join("")} 728 | 729 | 730 |
`; 731 | const detailDeletedFiles = `
732 | 733 | ${deletingFiles.map((e) => `- ${e}`).join("\n")} 734 | 735 |
`; 736 | const deleteMessage = 737 | deleteMissing && deletingFiles.length > 0 738 | ? `And ${deletingFiles.length} files will be deleted.\n${detailDeletedFiles}\n` 739 | : ""; 740 | const message = `We have ${processFileCount} files to restore on ${zipFileMap.size} ZIPs. \n${detailFiles}\n${deleteMessage}Are you sure to proceed?`; 741 | const RESTORE_BUTTON = "Yes, restore them!"; 742 | const CANCEL = "Cancel"; 743 | if ( 744 | (await confirmWithMessage(this, "Restore Confirmation", message, [RESTORE_BUTTON, CANCEL], CANCEL)) != 745 | RESTORE_BUTTON 746 | ) { 747 | this.logMessage(`Cancelled`); 748 | return; 749 | } 750 | for (const [zipName, files] of zipFileMap) { 751 | this.logMessage(`Extracting ${zipName}...`); 752 | await this.extract(zipName, files, undefined, prefix); 753 | } 754 | // console.dir(zipFileMap); 755 | } 756 | async onload() { 757 | await this.loadSettings(); 758 | if ("backupFolder" in this.settings) { 759 | this.settings.backupFolderMobile = this.settings.backupFolder as string; 760 | delete this.settings.backupFolder; 761 | } 762 | this.app.workspace.onLayoutReady(() => this.onLayoutReady()); 763 | 764 | this.addCommand({ 765 | id: "a-find-from-backups", 766 | name: "Restore from backups", 767 | callback: async () => { 768 | const d = new RestoreDialog(this.app, this); 769 | d.open(); 770 | }, 771 | }); 772 | this.addCommand({ 773 | id: "find-from-backups-old", 774 | name: "Restore from backups (previous behaviour)", 775 | callback: async () => { 776 | await this.selectAndRestore(); 777 | }, 778 | }); 779 | 780 | this.addCommand({ 781 | id: "find-from-backups-dir", 782 | name: "Restore from backups per folder", 783 | callback: async () => { 784 | await this.selectAndRestoreFolder(); 785 | }, 786 | }); 787 | this.addCommand({ 788 | id: "b-create-diff-zip", 789 | name: "Create Differential Backup", 790 | callback: () => { 791 | this.createZip(true); 792 | }, 793 | }); 794 | this.addCommand({ 795 | id: "b-create-diff-zip-only-new", 796 | name: "Create Differential Backup Only Newer Files", 797 | callback: () => { 798 | this.createZip(true, [], true); 799 | }, 800 | }); 801 | this.addCommand({ 802 | id: "b-create-diff-zip-only-new-and-existing", 803 | name: "Create Non-Destructive Differential Backup", 804 | callback: () => { 805 | this.createZip(true, [], false, true); 806 | }, 807 | }); 808 | this.addCommand({ 809 | id: "b-create-diff-zip-only-new-and-existing-only-new", 810 | name: "Create Non-Destructive Differential Backup Only Newer Files", 811 | callback: () => { 812 | this.createZip(true, [], true, true); 813 | }, 814 | }); 815 | 816 | this.addCommand({ 817 | id: "vault-restore-from-backups-only-new", 818 | name: "Fetch all new files from the backups", 819 | callback: async () => { 820 | await this.restoreVault(true, false); 821 | }, 822 | }); 823 | this.addCommand({ 824 | id: "vault-restore-from-backups-with-deletion", 825 | name: "⚠ Restore Vault from backups and delete with deletion", 826 | callback: async () => { 827 | await this.restoreVault(false, true); 828 | }, 829 | }); 830 | this.addSettingTab(new DiffZipSettingTab(this.app, this)); 831 | } 832 | 833 | async loadSettings() { 834 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 835 | } 836 | 837 | async resetToC() { 838 | const toc = {} as FileInfos; 839 | const tocFilePath = this.backups.normalizePath(`${this.backupFolder}${this.sep}${InfoFile}`); 840 | // Update TOC 841 | if ( 842 | await this.backups.writeTOC( 843 | tocFilePath, 844 | new TextEncoder().encode(`\`\`\`\n${stringifyYaml(toc)}\n\`\`\`\n`) 845 | ) 846 | ) { 847 | this.logMessage(`Backup information has been reset`); 848 | } else { 849 | this.logMessage(`Backup information cannot reset`); 850 | } 851 | } 852 | 853 | async saveSettings() { 854 | await this.saveData(this.settings); 855 | } 856 | } 857 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "diffzip", 3 | "name": "Differential ZIP Backup", 4 | "version": "0.0.14", 5 | "minAppVersion": "0.15.0", 6 | "description": "Back our vault up with lesser storage.", 7 | "author": "vorotamoroz", 8 | "authorUrl": "https://github.com/vrtmrz", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "diffzip", 3 | "version": "0.0.14", 4 | "description": "Differential ZIP Backup", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json", 10 | "pretty": "npm run prettyNoWrite -- --write --log-level error", 11 | "prettyCheck": "npm run prettyNoWrite -- --check", 12 | "prettyNoWrite": "prettier --config ./.prettierrc \"*.ts\" " 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "MIT", 17 | "devDependencies": { 18 | "@aws-sdk/client-s3": "^3.726.1", 19 | "@smithy/fetch-http-handler": "^5.0.1", 20 | "@smithy/protocol-http": "^5.0.1", 21 | "@smithy/querystring-builder": "^4.0.1", 22 | "@types/node": "^22.10.6", 23 | "@typescript-eslint/eslint-plugin": "8.20.0", 24 | "@typescript-eslint/parser": "8.20.0", 25 | "builtin-modules": "4.0.0", 26 | "esbuild": "0.24.2", 27 | "obsidian": "^1.8.7", 28 | "tslib": "2.8.1", 29 | "typescript": "5.7.3", 30 | "@tsconfig/svelte": "^5.0.4", 31 | "eslint-plugin-svelte": "^2.46.1", 32 | "esbuild-svelte": "^0.9.0", 33 | "svelte": "^5.1.15", 34 | "svelte-check": "^4.0.7", 35 | "svelte-preprocess": "^6.0.3", 36 | "prettier": "^3.5.3" 37 | }, 38 | "dependencies": { 39 | "fflate": "^0.8.2", 40 | "octagonal-wheels": "^0.1.30" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /storage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Abstract class for storage accessors and its implementations. 3 | */ 4 | import type DiffZipBackupPlugin from "./main"; 5 | import { S3 } from "@aws-sdk/client-s3"; 6 | import { ObsHttpHandler } from "./ObsHttpHandler"; 7 | import type { promises } from "node:fs"; 8 | import { normalizePath, TFile, TFolder, type Stat } from "obsidian"; 9 | import { OpenSSLCompat } from "octagonal-wheels/encryption"; 10 | const decryptCompatOpenSSL = OpenSSLCompat.CBC.decryptCBC; 11 | const encryptCompatOpenSSL = OpenSSLCompat.CBC.encryptCBC; 12 | 13 | export enum FileType { 14 | "Missing", 15 | "File", 16 | "Folder", 17 | } 18 | 19 | type FsAPI = { 20 | mkdir: typeof promises.mkdir; 21 | writeFile: typeof promises.writeFile; 22 | readFile: typeof promises.readFile; 23 | stat: typeof promises.stat; 24 | }; 25 | 26 | export type StorageAccessorType = "normal" | "direct" | "external" | "s3"; 27 | 28 | export abstract class StorageAccessor { 29 | type: StorageAccessorType; 30 | abstract sep: string; 31 | public plugin: DiffZipBackupPlugin; 32 | get app() { 33 | return this.plugin.app; 34 | } 35 | get settings() { 36 | return this.plugin.settings; 37 | } 38 | public basePath: string; 39 | get rootPath() { 40 | if (this.basePath == "") return ""; 41 | return this.basePath + this.sep; 42 | } 43 | public isLocal: boolean = false; 44 | 45 | constructor(plugin: DiffZipBackupPlugin, basePath?: string, isLocal?: boolean) { 46 | this.basePath = basePath || ""; 47 | this.plugin = plugin; 48 | this.isLocal = isLocal || false; 49 | } 50 | 51 | abstract createFolder(absolutePath: string): Promise; 52 | abstract checkType(path: string): Promise; 53 | 54 | async isFolderExists(path: string): Promise { 55 | return (await this.checkType(path)) == FileType.Folder; 56 | } 57 | async isFileExists(path: string): Promise { 58 | return (await this.checkType(path)) == FileType.File; 59 | } 60 | async isExists(path: string): Promise { 61 | return (await this.checkType(path)) != FileType.Missing; 62 | } 63 | 64 | async readBinary(path: string): Promise { 65 | const encryptedData = await this._readBinary(path); 66 | if (encryptedData === false) return false; 67 | if (!this.isLocal && this.settings.passphraseOfZip) { 68 | return await decryptCompatOpenSSL(new Uint8Array(encryptedData), this.settings.passphraseOfZip, 10000); 69 | } 70 | return encryptedData; 71 | } 72 | 73 | async readTOC(path: string): Promise { 74 | if (this.type != "normal") return await this.readBinary(path); 75 | return await this._readBinary(path, true); 76 | } 77 | 78 | async writeBinary(path: string, data: ArrayBuffer): Promise { 79 | let content = data; 80 | if (!this.isLocal && this.settings.passphraseOfZip) { 81 | content = await encryptCompatOpenSSL(new Uint8Array(data), this.settings.passphraseOfZip, 10000); 82 | } 83 | await this.ensureDirectory(path); 84 | return await this._writeBinary(path, content); 85 | } 86 | 87 | async writeTOC(path: string, data: ArrayBuffer): Promise { 88 | if (this.type != "normal") return this.writeBinary(path, data); 89 | await this.ensureDirectory(path); 90 | return await this._writeBinary(path, data); 91 | } 92 | 93 | abstract _writeBinary(path: string, data: ArrayBuffer): Promise; 94 | abstract _readBinary(path: string, preventUseCache?: boolean): Promise; 95 | 96 | normalizePath(path: string): string { 97 | return normalizePath(path); 98 | } 99 | abstract stat(path: string): Promise; 100 | 101 | async ensureDirectory(fullPath: string) { 102 | const pathElements = (this.rootPath + fullPath).split(this.sep); 103 | pathElements.pop(); 104 | let c = ""; 105 | for (const v of pathElements) { 106 | c += v; 107 | const type = await this.checkType(c); 108 | if (type == FileType.File) { 109 | throw new Error("File exists with the same name."); 110 | } else if (type == FileType.Missing) { 111 | await this.createFolder(c); 112 | } 113 | c += this.sep; 114 | } 115 | } 116 | } 117 | 118 | export class NormalVault extends StorageAccessor { 119 | type = "normal" as const; 120 | 121 | sep = "/"; // Always use / as separator on vault. 122 | 123 | async createFolder(absolutePath: string): Promise { 124 | await this.app.vault.createFolder(absolutePath); 125 | } 126 | 127 | async checkType(path: string): Promise { 128 | const af = this.app.vault.getAbstractFileByPath(path); 129 | if (af == null) return FileType.Missing; 130 | if (af instanceof TFile) return FileType.File; 131 | if (af instanceof TFolder) return FileType.Folder; 132 | throw new Error("Unknown file type."); 133 | } 134 | 135 | async _writeBinary(path: string, data: ArrayBuffer): Promise { 136 | try { 137 | const af = this.app.vault.getAbstractFileByPath(path); 138 | if (af == null) { 139 | await this.app.vault.createBinary(path, data); 140 | return true; 141 | } 142 | if (af instanceof TFile) { 143 | await this.app.vault.modifyBinary(af, data); 144 | return true; 145 | } 146 | } catch (e) { 147 | console.error(e); 148 | return false; 149 | } 150 | throw new Error("Folder exists with the same name."); 151 | } 152 | 153 | async _readBinary(path: string) { 154 | if (!(await this.isFileExists(path))) return false; 155 | return this.app.vault.adapter.readBinary(path); 156 | } 157 | 158 | async stat(path: string): Promise { 159 | const af = this.app.vault.getAbstractFileByPath(path); 160 | if (af == null) return false; 161 | if (af instanceof TFile) { 162 | return { 163 | type: "file", 164 | mtime: af.stat.mtime, 165 | size: af.stat.size, 166 | ctime: af.stat.ctime, 167 | }; 168 | } else if (af instanceof TFolder) { 169 | return { 170 | type: "folder", 171 | mtime: 0, 172 | ctime: 0, 173 | size: 0, 174 | }; 175 | } 176 | throw new Error("Unknown file type."); 177 | } 178 | } 179 | 180 | export class DirectVault extends StorageAccessor { 181 | type = "direct" as const; 182 | 183 | // constructor(plugin: DiffZipBackupPlugin, basePath?: string) { 184 | // super(plugin, basePath); 185 | // } 186 | sep = "/"; // Always use / as separator on vault. 187 | 188 | async createFolder(absolutePath: string): Promise { 189 | await this.app.vault.adapter.mkdir(absolutePath); 190 | } 191 | 192 | async checkType(path: string): Promise { 193 | const existence = await this.app.vault.adapter.exists(path); 194 | if (!existence) return FileType.Missing; 195 | const stat = await this.app.vault.adapter.stat(path); 196 | if (stat && stat.type == "folder") return FileType.Folder; 197 | return FileType.File; 198 | } 199 | 200 | async _writeBinary(path: string, data: ArrayBuffer): Promise { 201 | try { 202 | await this.app.vault.adapter.writeBinary(path, data); 203 | return true; 204 | } catch (e) { 205 | console.error(e); 206 | return false; 207 | } 208 | } 209 | 210 | async _readBinary(path: string) { 211 | if (!(await this.isFileExists(path))) return false; 212 | return this.app.vault.adapter.readBinary(path); 213 | } 214 | 215 | async stat(path: string): Promise { 216 | const stat = await this.app.vault.adapter.stat(path); 217 | if (!stat) return false; 218 | return stat; 219 | } 220 | } 221 | 222 | export class ExternalVaultFilesystem extends StorageAccessor { 223 | type = "external" as const; 224 | 225 | get sep(): string { 226 | //@ts-ignore internal API 227 | return this.app.vault.adapter.path.sep; 228 | } 229 | get fsPromises(): FsAPI { 230 | //@ts-ignore internal API 231 | return this.app.vault.adapter.fsPromises; 232 | } 233 | 234 | async createFolder(absolutePath: string): Promise { 235 | await this.fsPromises.mkdir(absolutePath, { recursive: true }); 236 | } 237 | 238 | async ensureDirectory(fullPath: string) { 239 | const delimiter = this.sep; 240 | const pathElements = fullPath.split(delimiter); 241 | pathElements.pop(); 242 | const mkPath = pathElements.join(delimiter); 243 | return await this.createFolder(mkPath); 244 | } 245 | 246 | async _writeBinary(fullPath: string, data: ArrayBuffer) { 247 | try { 248 | await this.fsPromises.writeFile(fullPath, Buffer.from(data)); 249 | return true; 250 | } catch (e) { 251 | console.error(e); 252 | return false; 253 | } 254 | } 255 | 256 | async _readBinary(path: string): Promise { 257 | return (await this.fsPromises.readFile(path)).buffer; 258 | } 259 | 260 | async checkType(path: string): Promise { 261 | try { 262 | const stat = await this.fsPromises.stat(path); 263 | if (stat.isDirectory()) return FileType.Folder; 264 | if (stat.isFile()) return FileType.File; 265 | // If it is not file or folder, then it is missing. 266 | // This is not possible in normal cases. 267 | return FileType.Missing; 268 | } catch { 269 | return FileType.Missing; 270 | } 271 | } 272 | 273 | normalizePath(path: string): string { 274 | //@ts-ignore internal API 275 | const f = this.app.vault.adapter.path; 276 | return f.normalize(path); 277 | } 278 | 279 | stat(path: string): Promise { 280 | throw new Error("Unsupported operation."); 281 | } 282 | } 283 | 284 | export class S3Bucket extends StorageAccessor { 285 | type = "s3" as const; 286 | sep = "/"; 287 | 288 | createFolder(absolutePath: string): Promise { 289 | // S3 does not have folder concept. So, we don't need to create folder. 290 | return Promise.resolve(); 291 | } 292 | ensureDirectory(fullPath: string): Promise { 293 | return Promise.resolve(); 294 | } 295 | 296 | async checkType(path: string): Promise { 297 | const client = await this.getClient(); 298 | try { 299 | await client.headObject({ 300 | Bucket: this.settings.bucket, 301 | Key: path, 302 | }); 303 | return FileType.File; 304 | } catch { 305 | return FileType.Missing; 306 | } 307 | } 308 | 309 | async getClient() { 310 | const client = new S3({ 311 | endpoint: this.settings.endPoint, 312 | region: this.settings.region, 313 | forcePathStyle: true, 314 | credentials: { 315 | accessKeyId: this.settings.accessKey, 316 | secretAccessKey: this.settings.secretKey, 317 | }, 318 | requestHandler: this.settings.useCustomHttpHandler ? new ObsHttpHandler(undefined, undefined) : undefined, 319 | }); 320 | return client; 321 | } 322 | 323 | async _writeBinary(fullPath: string, data: ArrayBuffer) { 324 | const client = await this.getClient(); 325 | try { 326 | const r = await client.putObject({ 327 | Bucket: this.settings.bucket, 328 | Key: fullPath, 329 | Body: new Uint8Array(data), 330 | }); 331 | if (~~((r.$metadata.httpStatusCode ?? 500) / 100) == 2) { 332 | return true; 333 | } else { 334 | console.error(`Failed to write binary to ${fullPath} (response code:${r.$metadata.httpStatusCode}).`); 335 | } 336 | return false; 337 | } catch (e) { 338 | console.error(e); 339 | return false; 340 | } 341 | } 342 | 343 | async _readBinary(fullPath: string, preventCache = false) { 344 | const client = await this.getClient(); 345 | const result = await client.getObject({ 346 | Bucket: this.settings.bucket, 347 | Key: fullPath, 348 | IfNoneMatch: preventCache ? "*" : undefined, 349 | }); 350 | if (!result.Body) return false; 351 | return await result.Body.transformToByteArray(); 352 | } 353 | 354 | stat(path: string): Promise { 355 | throw new Error("Unsupported operation."); 356 | } 357 | } 358 | 359 | export function getStorageType(plugin: DiffZipBackupPlugin): StorageAccessorType { 360 | if (plugin.isDesktopMode) { 361 | return "external"; 362 | } else if (plugin.settings.bucketEnabled) { 363 | return "s3"; 364 | } else { 365 | return "normal"; 366 | } 367 | } 368 | 369 | export function getStorageInstance( 370 | type: StorageAccessorType, 371 | plugin: DiffZipBackupPlugin, 372 | basePath?: string, 373 | isLocal?: boolean 374 | ): StorageAccessor { 375 | if (type == "external") { 376 | return new ExternalVaultFilesystem(plugin, basePath, isLocal); 377 | } else if (type == "s3") { 378 | return new S3Bucket(plugin, basePath, isLocal); 379 | } else if (type == "direct") { 380 | return new DirectVault(plugin, basePath, isLocal); 381 | } else { 382 | return new NormalVault(plugin, basePath, isLocal); 383 | } 384 | } 385 | 386 | export function getStorage(plugin: DiffZipBackupPlugin, basePath?: string, isLocal?: boolean): StorageAccessor { 387 | const type = getStorageType(plugin); 388 | return getStorageInstance(type, plugin, basePath, isLocal); 389 | } 390 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .diffzip-dialog-header { 2 | display: flex; 3 | flex-direction: row; 4 | flex-shrink: 0; 5 | flex-direction: column; 6 | } 7 | 8 | .diffzip-list { 9 | display: flex; 10 | flex-direction: column; 11 | flex-grow: 1; 12 | overflow-y: auto; 13 | } 14 | 15 | .diffzip-dialog-footer { 16 | border-top: 1px solid var(--background-modifier-border); 17 | padding-top: 0.75em; 18 | display: flex; 19 | flex-direction: row; 20 | flex-shrink: 0; 21 | flex-direction: column; 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "bundler", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "verbatimModuleSyntax": true, 15 | "lib": [ 16 | "DOM", 17 | "ES5", 18 | "ES6", 19 | "ES7" 20 | ] 21 | }, 22 | "include": [ 23 | "**/*.ts" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | import type { Notice } from "obsidian"; 2 | 3 | export enum AutoBackupType { 4 | FULL = "", 5 | ONLY_NEW = "only-new", 6 | ONLY_NEW_AND_EXISTING = "only-new-and-existing", 7 | } 8 | export const InfoFile = `backupinfo.md`; 9 | export interface DiffZipBackupSettings { 10 | backupFolder?: string; 11 | backupFolderMobile: string; 12 | backupFolderBucket: string; 13 | restoreFolder: string; 14 | maxSize: number; 15 | maxFilesInZip: number; 16 | performNextBackupOnMaxFiles: boolean; 17 | startBackupAtLaunch: boolean; 18 | startBackupAtLaunchType: AutoBackupType; 19 | includeHiddenFolder: boolean; 20 | desktopFolderEnabled: boolean; 21 | BackupFolderDesktop: string; 22 | bucketEnabled: boolean; 23 | 24 | endPoint: string; 25 | accessKey: string; 26 | secretKey: string; 27 | bucket: string; 28 | region: string; 29 | passphraseOfFiles: string; 30 | passphraseOfZip: string; 31 | useCustomHttpHandler: boolean; 32 | } 33 | export const DEFAULT_SETTINGS: DiffZipBackupSettings = { 34 | startBackupAtLaunch: false, 35 | startBackupAtLaunchType: AutoBackupType.ONLY_NEW_AND_EXISTING, 36 | backupFolderMobile: "backup", 37 | BackupFolderDesktop: "c:\\temp\\backup", 38 | backupFolderBucket: "backup", 39 | restoreFolder: "restored", 40 | includeHiddenFolder: false, 41 | maxSize: 30, 42 | desktopFolderEnabled: false, 43 | bucketEnabled: false, 44 | endPoint: "", 45 | accessKey: "", 46 | secretKey: "", 47 | region: "", 48 | bucket: "diffzip", 49 | maxFilesInZip: 100, 50 | performNextBackupOnMaxFiles: true, 51 | useCustomHttpHandler: false, 52 | passphraseOfFiles: "", 53 | passphraseOfZip: "", 54 | }; 55 | export type FileInfo = { 56 | filename: string; 57 | digest: string; 58 | history: { zipName: string; modified: string; missing?: boolean; processed?: number; digest: string }[]; 59 | mtime: number; 60 | processed?: number; 61 | missing?: boolean; 62 | }; 63 | export type FileInfos = Record; 64 | export type NoticeWithTimer = { 65 | notice: Notice; 66 | timer?: ReturnType; 67 | }; 68 | -------------------------------------------------------------------------------- /util.ts: -------------------------------------------------------------------------------- 1 | export function* pieces(source: Uint8Array, chunkSize: number): Generator { 2 | let offset = 0; 3 | while (offset < source.length) { 4 | yield source.slice(offset, offset + chunkSize); 5 | offset += chunkSize; 6 | } 7 | } 8 | export async function computeDigest(data: Uint8Array) { 9 | const hashBuffer = await crypto.subtle.digest("SHA-256", data); 10 | const hashArray = Array.from(new Uint8Array(hashBuffer)); 11 | const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); 12 | return hashHex; 13 | } 14 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.15.0" 3 | } 4 | --------------------------------------------------------------------------------