├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── LICENSE ├── README.md ├── esbuild.config.mjs ├── main.ts ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── Archive.ts ├── CombinedFragment.ts ├── DiffZipSettingTab.ts ├── ObsHttpHandler.ts ├── ProgressFragment.ts ├── RestoreFileInfo.svelte ├── RestoreFiles.svelte ├── RestoreView.ts ├── StorageAccessor │ ├── DirectVault.ts │ ├── ExternalVaultFilesystem.ts │ ├── NormalVault.ts │ ├── S3Bucket.ts │ └── StorageAccessor.ts ├── dialog.ts ├── storage.ts ├── types.ts └── util.ts ├── styles.css ├── tsconfig.json ├── 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: '22.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 | - name: Build 27 | id: build 28 | run: | 29 | npm ci 30 | npm run build --if-present 31 | # Package the required files into a zip 32 | - name: Package 33 | run: | 34 | mkdir ${{ github.event.repository.name }} 35 | cp main.js manifest.json README.md ${{ github.event.repository.name }} 36 | zip -r ${{ github.event.repository.name }}.zip ${{ github.event.repository.name }} 37 | # Create the release on github 38 | - name: Create Release 39 | id: create_release 40 | uses: actions/create-release@v1 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | VERSION: ${{ github.ref }} 44 | with: 45 | tag_name: ${{ github.ref }} 46 | release_name: ${{ github.ref }} 47 | draft: true 48 | prerelease: false 49 | # Upload the packaged release file 50 | - name: Upload zip file 51 | id: upload-zip 52 | uses: actions/upload-release-asset@v1 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | with: 56 | upload_url: ${{ steps.create_release.outputs.upload_url }} 57 | asset_path: ./${{ github.event.repository.name }}.zip 58 | asset_name: ${{ github.event.repository.name }}-${{ steps.version.outputs.tag }}.zip 59 | asset_content_type: application/zip 60 | # Upload the main.js 61 | - name: Upload main.js 62 | id: upload-main 63 | uses: actions/upload-release-asset@v1 64 | env: 65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | with: 67 | upload_url: ${{ steps.create_release.outputs.upload_url }} 68 | asset_path: ./main.js 69 | asset_name: main.js 70 | asset_content_type: text/javascript 71 | # Upload the manifest.json 72 | - name: Upload manifest.json 73 | id: upload-manifest 74 | uses: actions/upload-release-asset@v1 75 | env: 76 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 77 | with: 78 | upload_url: ${{ steps.create_release.outputs.upload_url }} 79 | asset_path: ./manifest.json 80 | asset_name: manifest.json 81 | asset_content_type: application/json 82 | # Upload the style.css 83 | - name: Upload styles.css 84 | id: upload-css 85 | uses: actions/upload-release-asset@v1 86 | env: 87 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 88 | with: 89 | upload_url: ${{ steps.create_release.outputs.upload_url }} 90 | asset_path: ./styles.css 91 | asset_name: styles.css 92 | asset_content_type: text/css 93 | # TODO: release notes??? 94 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 { 4 | getStorageForBackup, 5 | getStorageForVault, 6 | getStorageTypeForBackupAccess, 7 | getStorageTypeForVaultAccess, 8 | } from "./src/storage.ts"; 9 | import { type StorageAccessor } from "./src/StorageAccessor/StorageAccessor.ts"; 10 | import { RestoreDialog } from "./src/RestoreView.ts"; 11 | import { confirmWithMessage, askSelectString } from "./src/dialog.ts"; 12 | import { Archiver, Extractor } from "./src/Archive.ts"; 13 | import { computeDigest, pieces, toArrayBuffer } from "./src/util.ts"; 14 | import { 15 | AutoBackupType, 16 | DEFAULT_SETTINGS, 17 | InfoFile, 18 | type DiffZipBackupSettings, 19 | type FileInfo, 20 | type FileInfos, 21 | type NoticeWithTimer, 22 | } from "./src/types.ts"; 23 | import { DiffZipSettingTab } from "./src/DiffZipSettingTab.ts"; 24 | import { ProgressFragment } from "./src/ProgressFragment.ts"; 25 | import { CombinedFragment } from "./src/CombinedFragment.ts"; 26 | 27 | export default class DiffZipBackupPlugin extends Plugin { 28 | settings: DiffZipBackupSettings; 29 | 30 | get isMobile(): boolean { 31 | // @ts-ignore 32 | return this.app.isMobile; 33 | } 34 | get isDesktopMode(): boolean { 35 | return this.settings.desktopFolderEnabled && !this.isMobile; 36 | } 37 | 38 | get backupFolder(): string { 39 | if (this.settings.bucketEnabled) return this.settings.backupFolderBucket; 40 | return this.isDesktopMode ? this.settings.BackupFolderDesktop : this.settings.backupFolderMobile; 41 | } 42 | 43 | _backups: StorageAccessor; 44 | get backups(): StorageAccessor { 45 | const type = getStorageTypeForBackupAccess(this); 46 | if (!this._backups || this._backups.type != type) { 47 | this._backups = getStorageForBackup(this); 48 | } 49 | return this._backups; 50 | } 51 | _vaultAccess: StorageAccessor; 52 | get vaultAccess(): StorageAccessor { 53 | const type = getStorageTypeForVaultAccess(this); 54 | if (!this._vaultAccess || this._vaultAccess.type != type) { 55 | this._vaultAccess = getStorageForVault(this); 56 | } 57 | return this._vaultAccess; 58 | } 59 | 60 | get sep(): string { 61 | //@ts-ignore 62 | return this.isDesktopMode ? this.app.vault.adapter.path.sep : "/"; 63 | } 64 | 65 | messages = {} as Record; 66 | 67 | logMessage(message: string, key?: string) { 68 | this.logWrite(message, key); 69 | if (!key) { 70 | new Notice(message, 3000); 71 | return; 72 | } 73 | let n: NoticeWithTimer | undefined = undefined; 74 | if (key in this.messages) { 75 | n = this.messages[key]; 76 | clearTimeout(n.timer); 77 | if (!n.notice.noticeEl.isShown()) { 78 | delete this.messages[key]; 79 | } else { 80 | n.notice.setMessage(message); 81 | } 82 | } 83 | if (!n || !(key in this.messages)) { 84 | n = { 85 | notice: new Notice(message, 0), 86 | }; 87 | } 88 | n.timer = setTimeout(() => { 89 | n?.notice?.hide(); 90 | }, 5000); 91 | this.messages[key] = n; 92 | } 93 | 94 | hideMessage(key: string) { 95 | const n = this.messages[key]; 96 | if (n) { 97 | clearTimeout(n.timer); 98 | n.notice.hide(); 99 | delete this.messages[key]; 100 | } 101 | } 102 | logWrite(message: string, key?: string) { 103 | const dt = new Date().toLocaleString(); 104 | console.log(`${dt}\t${message}`); 105 | } 106 | 107 | async getFiles(path: string, ignoreList: string[], progress: ProgressFragment) { 108 | const pathPart = ellipsisMiddle(path); 109 | progress.note = `Scanning ${pathPart}`; 110 | const w = await this.app.vault.adapter.list(path); 111 | progress.total += w.folders.length; 112 | let files = [...w.files.filter((e) => !ignoreList.some((ee) => e.endsWith(ee)))]; 113 | L1: for (const v of w.folders) { 114 | for (const ignore of ignoreList) { 115 | if (v.endsWith(ignore)) { 116 | progress.value++; 117 | continue L1; 118 | } 119 | } 120 | // files = files.concat([v]); 121 | files = files.concat(await this.getFiles(v, ignoreList, progress)); 122 | progress.value++; 123 | } 124 | return files; 125 | } 126 | 127 | async loadTOC() { 128 | let toc = {} as FileInfos; 129 | const tocFilePath = this.backups.normalizePath(`${this.backupFolder}${this.sep}${InfoFile}`); 130 | const tocExist = await this.backups.isFileExists(tocFilePath); 131 | if (tocExist) { 132 | this.logWrite(`Loading Backup information`, "proc-index"); 133 | try { 134 | const tocBin = await this.backups.readTOC(tocFilePath); 135 | if (tocBin == null || tocBin === false) { 136 | this.logMessage(`LOAD ERROR: Could not read Backup information`, "proc-index"); 137 | return {}; 138 | } 139 | const tocStr = new TextDecoder().decode(tocBin); 140 | toc = parseYaml(tocStr.replace(/^```$/gm, "")); 141 | if (toc == null) { 142 | this.logMessage(`PARSE ERROR: Could not parse Backup information`, "proc-index"); 143 | toc = {}; 144 | } else { 145 | this.logWrite(`Backup information has been loaded`, "proc-index"); 146 | } 147 | } catch (ex) { 148 | this.logMessage(`Something went wrong while parsing Backup information`, "proc-index"); 149 | console.warn(ex); 150 | toc = {}; 151 | } 152 | } else { 153 | this.logMessage(`Backup information looks missing`, "proc-index"); 154 | } 155 | return toc; 156 | } 157 | 158 | async getAllFiles() { 159 | const ignores = [ 160 | "node_modules", 161 | ".git", 162 | this.app.vault.configDir + "/trash", 163 | this.app.vault.configDir + "/workspace.json", 164 | this.app.vault.configDir + "/workspace-mobile.json", 165 | ]; 166 | if (this.settings.includeHiddenFolder) { 167 | const progress = new ProgressFragment({ 168 | title: "Gathering Files", 169 | value: 0, 170 | total: 0, 171 | onComplete: () => { 172 | setTimeout(() => { 173 | notice.hide(); 174 | }, 1000); 175 | }, 176 | }); 177 | const notice = new Notice(progress.fragment, 0); 178 | return (await this.getFiles("", ignores, progress)).filter((e) => !e.startsWith(".trash/")); 179 | } 180 | return this.app.vault.getFiles().map((e) => e.path); 181 | } 182 | 183 | async createZip(verbosity: boolean, skippableFiles: string[] = [], onlyNew = false, skipDeleted: boolean = false) { 184 | const key = "proc-zip-process-" + Date.now(); 185 | const log = verbosity 186 | ? (msg: string, key?: string) => this.logWrite(msg, key) 187 | : (msg: string, key?: string) => this.logMessage(msg, key); 188 | const allFiles = await this.getAllFiles(); 189 | const toc = await this.loadTOC(); 190 | const today = new Date(); 191 | const secondsInDay = ~~(today.getTime() / 1000 - today.getTimezoneOffset() * 60) % 86400; 192 | 193 | const newFileName = `${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate()}-${secondsInDay}.zip`; 194 | 195 | // Find missing files 196 | let missingFiles = 0; 197 | let progressNotice: Notice | undefined; 198 | let onProgress = () => {}; 199 | const fragmentOption = { 200 | total: 0, 201 | onComplete: () => onCloseProgress(), 202 | onProgress: () => onProgress(), 203 | } as const; 204 | 205 | const missingFileProgress = new ProgressFragment({ 206 | title: "Checking file and TOC", 207 | ...fragmentOption, 208 | }); 209 | const checkingProgress = new ProgressFragment({ 210 | title: "Check and Archiving Files", 211 | ...fragmentOption, 212 | }); 213 | const fileProcessingProgress = new ProgressFragment({ 214 | title: "File Processing", 215 | ...fragmentOption, 216 | }); 217 | const fileArchivedProgress = new ProgressFragment({ 218 | title: "Archiving Files", 219 | ...fragmentOption, 220 | }); 221 | const uploadingProgress = new ProgressFragment({ 222 | title: "Committing ZIP Files", 223 | ...fragmentOption, 224 | }); 225 | const combinedFragment = new CombinedFragment([ 226 | () => missingFileProgress.reconstructFragment(), 227 | () => checkingProgress.reconstructFragment(), 228 | () => fileProcessingProgress.reconstructFragment(), 229 | () => fileArchivedProgress.reconstructFragment(), 230 | () => uploadingProgress.reconstructFragment(), 231 | ]); 232 | 233 | const isClosed = () => { 234 | return progressNotice == undefined || !progressNotice.noticeEl.isShown(); 235 | }; 236 | 237 | onProgress = () => { 238 | if (!isClosed()) return; 239 | progressNotice = new Notice(combinedFragment.rebuildFragment(), 0); 240 | }; 241 | 242 | const onCloseProgress = () => { 243 | if ( 244 | [ 245 | missingFileProgress, 246 | checkingProgress, 247 | fileProcessingProgress, 248 | fileArchivedProgress, 249 | uploadingProgress, 250 | ].every((e) => e.isCompleted || e.isCancelled) 251 | ) { 252 | setTimeout(() => { 253 | progressNotice?.hide(); 254 | progressNotice = undefined; 255 | }, 3000); 256 | } 257 | }; 258 | 259 | missingFileProgress.total = Object.keys(toc).length; 260 | for (const [filename, fileInfo] of Object.entries(toc)) { 261 | try { 262 | if (fileInfo.missing) continue; 263 | if (!(await this.vaultAccess.isFileExists(this.vaultAccess.normalizePath(filename)))) { 264 | if (skipDeleted) continue; 265 | fileInfo.missing = true; 266 | fileInfo.digest = ""; 267 | fileInfo.mtime = today.getTime(); 268 | fileInfo.processed = today.getTime(); 269 | log(`File ${filename} is missing`); 270 | fileInfo.history = [ 271 | ...fileInfo.history, 272 | { 273 | zipName: newFileName, 274 | modified: today.toISOString(), 275 | missing: true, 276 | processed: today.getTime(), 277 | digest: "", 278 | }, 279 | ]; 280 | log(`History of ${filename} has been updated (Missing)`); 281 | missingFiles++; 282 | } 283 | } finally { 284 | missingFileProgress.value++; 285 | } 286 | } 287 | 288 | const zip = new Archiver(); 289 | 290 | const normalFiles = allFiles 291 | .filter( 292 | (e) => 293 | !e.startsWith(this.backupFolder + this.sep) && !e.startsWith(this.settings.restoreFolder + this.sep) 294 | ) 295 | .filter((e) => skippableFiles.indexOf(e) == -1); 296 | checkingProgress.total = normalFiles.length; 297 | let processed = 0; 298 | let processedSize = 0; 299 | let hasExtra = false; 300 | const processedFiles = [] as string[]; 301 | let zipped = 0; 302 | for (const path of normalFiles) { 303 | try { 304 | processedFiles.push(path); 305 | processed++; 306 | checkingProgress.note = `Processing ${ellipsisMiddle(path)}`; 307 | const stat = await this.vaultAccess.stat(path); 308 | if (!stat) { 309 | this.logMessage(`Archiving: Could not read stat ${path}`); 310 | continue; 311 | } 312 | // Check the file is in the skippable list 313 | if (onlyNew && path in toc) { 314 | const entry = toc[path]; 315 | const mtime = new Date(stat.mtime).getTime(); 316 | if (mtime <= entry.mtime) { 317 | this.logWrite(`${path} older than the last backup, skipping`); 318 | continue; 319 | } 320 | } 321 | // Read the file content 322 | const content = await this.vaultAccess.readBinary(path); 323 | if (!content) { 324 | this.logMessage(`Archiving: Could not read ${path}`); 325 | continue; 326 | } 327 | 328 | // Check the file actually modified. 329 | const f = new Uint8Array(content); 330 | const digest = await computeDigest(f); 331 | 332 | if (path in toc) { 333 | const entry = toc[path]; 334 | if (entry.digest == digest) { 335 | this.logWrite(`${path} Not changed`); 336 | continue; 337 | } 338 | } 339 | zipped++; 340 | processedSize += content.byteLength; 341 | 342 | // Update the file information 343 | toc[path] = { 344 | digest, 345 | filename: path, 346 | mtime: stat.mtime, 347 | processed: today.getTime(), 348 | history: [ 349 | ...(toc[path]?.history ?? []), 350 | { 351 | zipName: newFileName, 352 | modified: new Date(stat.mtime).toISOString(), 353 | processed: today.getTime(), 354 | digest, 355 | }, 356 | ], 357 | }; 358 | fileArchivedProgress.total++; 359 | fileArchivedProgress.note = `Archiving: ${ellipsisMiddle(path)}`; 360 | zip.addFile(f, path, { mtime: stat.mtime }, (processed, total, finished) => { 361 | if (!finished) { 362 | fileProcessingProgress.note = `Archiving: ${ellipsisMiddle(path)}`; 363 | fileProcessingProgress.total = total; 364 | fileProcessingProgress.value = processed; 365 | } else { 366 | fileArchivedProgress.value++; 367 | fileArchivedProgress.note = `Archived: ${ellipsisMiddle(path)}`; 368 | fileProcessingProgress.note = ""; 369 | fileProcessingProgress.isCancelled = true; 370 | fileProcessingProgress.total = 0; 371 | fileProcessingProgress.value = 0; 372 | } 373 | }); 374 | if (this.settings.maxFilesInZip > 0 && zipped >= this.settings.maxFilesInZip) { 375 | checkingProgress.total = zipped; 376 | checkingProgress.note = `⚠️ Max files in a single ZIP`; 377 | hasExtra = true; 378 | break; 379 | } 380 | if ( 381 | this.settings.maxTotalSizeInZip > 0 && 382 | processedSize >= this.settings.maxTotalSizeInZip * 1024 * 1024 383 | ) { 384 | checkingProgress.total = zipped; 385 | checkingProgress.note = `⚠️ Max total size in a single ZIP`; 386 | hasExtra = true; 387 | break; 388 | } 389 | } finally { 390 | checkingProgress.value++; 391 | } 392 | } 393 | if (!hasExtra) { 394 | checkingProgress.note = ``; 395 | } 396 | 397 | if (zipped == 0 && missingFiles == 0) { 398 | fileProcessingProgress.isCancelled = true; 399 | checkingProgress.isCancelled = true; 400 | fileArchivedProgress.isCancelled = true; 401 | uploadingProgress.note = `No files have been changed. \nSkipping ZIP generation...`; 402 | uploadingProgress.isCancelled = true; 403 | return; 404 | } 405 | const tocTimeStamp = new Date().getTime(); 406 | zip.addTextFile(`\`\`\`\n${stringifyYaml(toc)}\n\`\`\`\n`, InfoFile, { mtime: tocTimeStamp }); 407 | try { 408 | const buf = await zip.finalize(); 409 | uploadingProgress.total = buf.byteLength; 410 | // Writing a large file can cause the crash of Obsidian, and very heavy to synchronise. 411 | // Hence, we have to split the file into a smaller size. 412 | const step = 413 | this.settings.maxSize / 1 == 0 ? buf.byteLength + 1 : (this.settings.maxSize / 1) * 1024 * 1024; 414 | let pieceCount = 0; 415 | // If the file size is smaller than the step, it will be a single file. 416 | // Otherwise, it will be split into multiple files. (start from 001) 417 | if (buf.byteLength > step) pieceCount = 1; 418 | 419 | const chunks = pieces(buf, step); 420 | for (const chunk of chunks) { 421 | const outZipFile = this.backups.normalizePath( 422 | `${this.backupFolder}${this.sep}${newFileName}${pieceCount == 0 ? "" : "." + `00${pieceCount}`.slice(-3)}` 423 | ); 424 | pieceCount++; 425 | uploadingProgress.note = `Committing ${ellipsisMiddle(outZipFile)}`; 426 | const e = await this.backups.writeBinary(outZipFile, toArrayBuffer(chunk)); 427 | if (!e) { 428 | throw new Error(`Creating ${outZipFile} has been failed!`); 429 | } 430 | uploadingProgress.value += chunk.byteLength; 431 | } 432 | 433 | const tocFilePath = this.backups.normalizePath(`${this.backupFolder}${this.sep}${InfoFile}`); 434 | 435 | // Update TOC 436 | if ( 437 | !(await this.backups.writeTOC( 438 | tocFilePath, 439 | toArrayBuffer(new TextEncoder().encode(`\`\`\`\n${stringifyYaml(toc)}\n\`\`\`\n`)) 440 | )) 441 | ) { 442 | throw new Error(`Updating TOC has been failed!`); 443 | } 444 | log(`Backup information has been updated`, key); 445 | if (hasExtra && this.settings.performNextBackupOnMaxFiles) { 446 | checkingProgress.isCancelled = true; 447 | setTimeout(() => { 448 | this.createZip(verbosity, [...skippableFiles, ...processedFiles], onlyNew, skipDeleted); 449 | }, 10); 450 | } else { 451 | this.logMessage( 452 | `${processed} of ${normalFiles.length} files have been processed, ${zipped} files have been zipped.`, 453 | key 454 | ); 455 | } 456 | // } else { 457 | // this.logMessage(`Backup has been aborted \n${processed} files, ${zipped} zip files`, "proc-zip-process"); 458 | // } 459 | } catch (e) { 460 | this.logMessage(`Something get wrong while processing ${processed} files, ${zipped} zip files`, key); 461 | this.logWrite(e); 462 | } 463 | } 464 | 465 | async extract(zipFile: string, extractFiles: string[]): Promise; 466 | async extract(zipFile: string, extractFiles: string, restoreAs: string): Promise; 467 | async extract(zipFile: string, extractFiles: string[], restoreAs: undefined, restorePrefix: string): Promise; 468 | async extract( 469 | zipFile: string, 470 | extractFiles: string | string[], 471 | restoreAs: string | undefined = undefined, 472 | restorePrefix: string = "" 473 | ): Promise { 474 | const hasMultipleSupplied = Array.isArray(extractFiles); 475 | const zipPath = this.backups.normalizePath(`${this.backupFolder}${this.sep}${zipFile}`); 476 | const zipF = await this.backups.isExists(zipPath); 477 | let files = [] as string[]; 478 | if (zipF) { 479 | files = [zipPath]; 480 | } else { 481 | let hasNext = true; 482 | let counter = 0; 483 | do { 484 | counter++; 485 | const partialZipPath = zipPath + "." + `00${counter}`.slice(-3); 486 | if (await this.backups.isExists(partialZipPath)) { 487 | files.push(partialZipPath); 488 | } else { 489 | hasNext = false; 490 | } 491 | } while (hasNext); 492 | } 493 | if (files.length == 0) { 494 | this.logMessage("Archived ZIP files were not found!"); 495 | } 496 | const restored = [] as string[]; 497 | 498 | const extractor = new Extractor( 499 | (file: fflate.UnzipFile) => { 500 | if (hasMultipleSupplied) { 501 | return extractFiles.indexOf(file.name) !== -1; 502 | } 503 | return file.name === extractFiles; 504 | }, 505 | async (file: string, dat: Uint8Array) => { 506 | const fileName = restoreAs ?? file; 507 | const restoreTo = hasMultipleSupplied ? `${restorePrefix}${fileName}` : fileName; 508 | if (await this.vaultAccess.writeBinary(restoreTo, toArrayBuffer(dat))) { 509 | restored.push(restoreTo); 510 | const files = restored.slice(-5).join("\n"); 511 | this.logMessage(`${restored.length} files have been restored! \n${files}\n...`, "proc-zip-extract"); 512 | } else { 513 | this.logMessage(`Creating or Overwriting ${file} has been failed!`); 514 | } 515 | } 516 | ); 517 | 518 | const size = 1024 * 1024; 519 | for (const file of files) { 520 | this.logMessage(`Processing ${file}...`, "proc-zip-export-processing"); 521 | const binary = await this.backups.readBinary(file); 522 | if (binary == null || binary === false) { 523 | this.logMessage(`Could not read ${file}`); 524 | return; 525 | } 526 | const chunks = pieces(new Uint8Array(binary), size); 527 | for await (const chunk of chunks) { 528 | extractor.addZippedContent(chunk); 529 | } 530 | } 531 | } 532 | 533 | async selectAndRestore() { 534 | const files = await this.loadTOC(); 535 | const filenames = Object.entries(files) 536 | .sort((a, b) => b[1].mtime - a[1].mtime) 537 | .map((e) => e[0]); 538 | if (filenames.length == 0) { 539 | return; 540 | } 541 | const selected = await askSelectString(this.app, "Select file", filenames); 542 | if (!selected) { 543 | return; 544 | } 545 | const revisions = files[selected].history; 546 | const d = `\u{2063}`; 547 | const revisionList = revisions.map((e) => `${e.zipName}${d} (${e.modified})`).reverse(); 548 | const selectedTimestamp = await askSelectString(this.app, "Select file", revisionList); 549 | if (!selectedTimestamp) { 550 | return; 551 | } 552 | const [filename] = selectedTimestamp.split(d); 553 | const suffix = filename.replace(".zip", ""); 554 | // No cares about without extension 555 | const extArr = selected.split("."); 556 | const ext = extArr.pop(); 557 | const selectedWithoutExt = extArr.join("."); 558 | const RESTORE_OVERWRITE = "Original place and okay to overwrite"; 559 | const RESTORE_TO_RESTORE_FOLDER = "Under the restore folder"; 560 | const RESTORE_WITH_SUFFIX = "Original place but with ZIP name suffix"; 561 | const restoreMethods = [RESTORE_TO_RESTORE_FOLDER, RESTORE_OVERWRITE, RESTORE_WITH_SUFFIX]; 562 | const howToRestore = await askSelectString(this.app, "Where to restore?", restoreMethods); 563 | const restoreAs = 564 | howToRestore == RESTORE_OVERWRITE 565 | ? selected 566 | : howToRestore == RESTORE_TO_RESTORE_FOLDER 567 | ? this.vaultAccess.normalizePath(`${this.settings.restoreFolder}${this.sep}${selected}`) 568 | : howToRestore == RESTORE_WITH_SUFFIX 569 | ? `${selectedWithoutExt}-${suffix}.${ext}` 570 | : ""; 571 | if (!restoreAs) { 572 | return; 573 | } 574 | await this.extract(filename, selected, restoreAs); 575 | } 576 | 577 | async pickRevisions(files: FileInfos, prefix = ""): Promise { 578 | const BACK = "[..]"; 579 | const timestamps = new Set(); 580 | const all = Object.entries(files).filter((e) => e[0].startsWith(prefix)); 581 | for (const f of all) { 582 | f[1].history.map((e) => e.modified).map((e) => timestamps.add(e)); 583 | } 584 | const modifiedList = [...timestamps].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).reverse(); 585 | modifiedList.unshift(BACK); 586 | const selected = await askSelectString(this.app, "Until?", modifiedList); 587 | if (!selected) { 588 | return ""; 589 | } 590 | return selected; 591 | } 592 | async selectAndRestoreFolder(filesSrc?: FileInfos, prefix = "") { 593 | if (!filesSrc) filesSrc = await this.loadTOC(); 594 | const files = JSON.parse(JSON.stringify({ ...filesSrc })) as typeof filesSrc; 595 | const level = prefix.split("/").filter((e) => !!e).length + 1; 596 | const filenamesAll = Object.entries(files) 597 | .sort((a, b) => b[1].mtime - a[1].mtime) 598 | .map((e) => e[0]); 599 | const filenamesFiltered = filenamesAll.filter((e) => e.startsWith(prefix)); 600 | const filenamesA = filenamesFiltered 601 | .map((e) => { 602 | const paths = e.split("/"); 603 | const name = paths.splice(0, level).join("/"); 604 | if (paths.length == 0 && name) return name; 605 | return `${name}/`; 606 | }) 607 | .sort((a, b) => { 608 | const isDirA = a.endsWith("/"); 609 | const isDirB = b.endsWith("/"); 610 | if (isDirA && !isDirB) return -1; 611 | if (!isDirA && isDirB) return 1; 612 | if (isDirA && isDirB) return a.localeCompare(b); 613 | return 0; 614 | }); 615 | 616 | const filenames = [...new Set(filenamesA)]; 617 | if (filenames.length == 0) { 618 | return; 619 | } 620 | 621 | const BACK = "[..]"; 622 | const ALL = "[ALL]"; 623 | 624 | filenames.unshift(ALL); 625 | filenames.unshift(BACK); 626 | 627 | const selected = await askSelectString(this.app, "Select file", filenames); 628 | if (!selected) { 629 | return; 630 | } 631 | if (selected == BACK) { 632 | const p = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; 633 | const parent = p.split("/").slice(0, -1).join("/"); 634 | await this.selectAndRestoreFolder(filesSrc, parent); 635 | return; 636 | } 637 | if (selected == ALL) { 638 | // Collect all files and timings 639 | const selectedThreshold = await this.pickRevisions(files, prefix); 640 | if (!selectedThreshold) { 641 | return; 642 | } 643 | if (selectedThreshold == BACK) { 644 | await this.selectAndRestoreFolder(filesSrc, prefix); 645 | return; 646 | } 647 | const allFiles = Object.entries(files).filter((e) => e[0].startsWith(prefix)); 648 | const maxDate = new Date(selectedThreshold).getTime(); 649 | const fileMap = new Map(); 650 | for (const [key, files] of allFiles) { 651 | for (const fileInfo of files.history) { 652 | //keep only the latest one 653 | const fileModified = new Date(fileInfo.modified).getTime(); 654 | if (fileModified > maxDate) continue; 655 | const info = fileMap.get(key); 656 | if (!info) { 657 | fileMap.set(key, fileInfo); 658 | } else { 659 | if (new Date(info.modified).getTime() < fileModified) { 660 | fileMap.set(key, fileInfo); 661 | } 662 | } 663 | } 664 | } 665 | const zipMap = new Map(); 666 | for (const [filename, fileInfo] of fileMap) { 667 | const path = fileInfo.zipName; 668 | const arr = zipMap.get(path) ?? []; 669 | arr.push(filename); 670 | zipMap.set(path, arr); 671 | } 672 | // const fileMap = new Map(); 673 | // for (const [zipName, fileInfo] of zipMap) { 674 | // const path = fileInfo.zipName; 675 | // fileMap.set(path, zipName); 676 | // } 677 | const zipList = [...zipMap.entries()].sort((a, b) => a[0].localeCompare(b[0])); 678 | const filesCount = zipList.reduce((a, b) => a + b[1].length, 0); 679 | if ( 680 | (await askSelectString( 681 | this.app, 682 | `Are you sure to restore(Overwrite) ${filesCount} files from ${zipList.length} ZIPs`, 683 | ["Y", "N"] 684 | )) != "Y" 685 | ) { 686 | this.logMessage(`Cancelled`); 687 | return; 688 | } 689 | this.logMessage(`Extract ${zipList.length} ZIPs`); 690 | let i = 0; 691 | for (const [zipName, files] of zipList) { 692 | i++; 693 | this.logMessage(`Extract ${files.length} files from ${zipName} (${i}/${zipList.length})`); 694 | await this.extract(zipName, files); 695 | } 696 | // console.dir(zipMap); 697 | 698 | return; 699 | } 700 | if (selected.endsWith("/")) { 701 | await this.selectAndRestoreFolder(filesSrc, selected); 702 | return; 703 | } 704 | const revisions = files[selected].history; 705 | const d = `\u{2063}`; 706 | const revisionList = revisions.map((e) => `${e.zipName}${d} (${e.modified})`).reverse(); 707 | revisionList.unshift(BACK); 708 | const selectedTimestamp = await askSelectString(this.app, "Select file", revisionList); 709 | if (!selectedTimestamp) { 710 | return; 711 | } 712 | if (selectedTimestamp == BACK) { 713 | await this.selectAndRestoreFolder(filesSrc, prefix); 714 | return; 715 | } 716 | const [filename] = selectedTimestamp.split(d); 717 | const suffix = filename.replace(".zip", ""); 718 | // No cares about without extension 719 | const extArr = selected.split("."); 720 | const ext = extArr.pop(); 721 | const selectedWithoutExt = extArr.join("."); 722 | const RESTORE_OVERWRITE = "Original place and okay to overwrite"; 723 | const RESTORE_TO_RESTORE_FOLDER = "Under the restore folder"; 724 | const RESTORE_WITH_SUFFIX = "Original place but with ZIP name suffix"; 725 | const restoreMethods = [RESTORE_TO_RESTORE_FOLDER, RESTORE_OVERWRITE, RESTORE_WITH_SUFFIX]; 726 | const howToRestore = await askSelectString(this.app, "Where to restore?", restoreMethods); 727 | const restoreAs = 728 | howToRestore == RESTORE_OVERWRITE 729 | ? selected 730 | : howToRestore == RESTORE_TO_RESTORE_FOLDER 731 | ? this.vaultAccess.normalizePath(`${this.settings.restoreFolder}${this.sep}${selected}`) 732 | : howToRestore == RESTORE_WITH_SUFFIX 733 | ? `${selectedWithoutExt}-${suffix}.${ext}` 734 | : ""; 735 | if (!restoreAs) { 736 | return; 737 | } 738 | await this.extract(filename, selected, restoreAs); 739 | } 740 | // _debugDialogue?: RestoreDialog; 741 | async onLayoutReady() { 742 | // if (this._debugDialogue) { 743 | // this._debugDialogue.close(); 744 | // this._debugDialogue = undefined; 745 | // } 746 | if (this.settings.startBackupAtLaunch) { 747 | const onlyNew = 748 | this.settings.startBackupAtLaunchType == AutoBackupType.ONLY_NEW || 749 | this.settings.startBackupAtLaunchType == AutoBackupType.ONLY_NEW_AND_EXISTING; 750 | const skipDeleted = this.settings.startBackupAtLaunchType == AutoBackupType.ONLY_NEW_AND_EXISTING; 751 | this.createZip(false, [], onlyNew, skipDeleted); 752 | } 753 | } 754 | // onunload(): void { 755 | // this._debugDialogue?.close(); 756 | // } 757 | 758 | async restoreVault( 759 | onlyNew = true, 760 | deleteMissing: boolean = false, 761 | fileFilter: Record | undefined = undefined, 762 | prefix: string = "" 763 | ) { 764 | this.logMessage(`Checking backup information...`); 765 | const files = await this.loadTOC(); 766 | // const latestZipMap = new Map(); 767 | const zipFileMap = new Map(); 768 | const thisPluginDir = this.manifest.dir; 769 | const deletingFiles = [] as string[]; 770 | let processFileCount = 0; 771 | for (const [filename, fileInfo] of Object.entries(files)) { 772 | if (fileFilter) { 773 | const matched = Object.keys(fileFilter) 774 | .filter((e) => (e.endsWith("*") ? filename.startsWith(e.slice(0, -1)) : e == filename)) 775 | .sort((a, b) => b.length - a.length); 776 | if (matched.length == 0) { 777 | this.logWrite(`${filename}: is not matched with supplied filter. Skipping...`); 778 | continue; 779 | } 780 | const matchedFilter = matched[0]; 781 | // remove history after the filter 782 | fileInfo.history = fileInfo.history.filter( 783 | (e) => new Date(e.modified).getTime() <= fileFilter[matchedFilter] 784 | ); 785 | } 786 | if (thisPluginDir && fileInfo.filename.startsWith(thisPluginDir)) { 787 | this.logWrite(`${filename} is a plugin file. Skipping on vault restoration`); 788 | continue; 789 | } 790 | const history = fileInfo.history; 791 | if (history.length == 0) { 792 | this.logWrite(`${filename}: has no history. Skipping...`); 793 | continue; 794 | } 795 | history.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime()); 796 | const latest = history[0]; 797 | const zipName = latest.zipName; 798 | const localFileName = this.vaultAccess.normalizePath(`${prefix}${filename}`); 799 | const localStat = await this.vaultAccess.stat(localFileName); 800 | if (localStat) { 801 | const content = await this.vaultAccess.readBinary(localFileName); 802 | if (!content) { 803 | this.logWrite(`${filename}: has been failed to read`); 804 | continue; 805 | } 806 | const localDigest = await computeDigest(new Uint8Array(content)); 807 | if (localDigest == latest?.digest) { 808 | this.logWrite(`${filename}: is as same as the backup. Skipping...`); 809 | continue; 810 | } 811 | if (fileInfo.missing) { 812 | if (!deleteMissing) { 813 | this.logWrite(`${filename}: is marked as missing, but existing in the vault. Skipping...`); 814 | continue; 815 | } else { 816 | // this.logWrite(`${filename}: is marked as missing. Deleting...`); 817 | deletingFiles.push(filename); 818 | //TODO: Delete the file 819 | } 820 | } 821 | const localMtime = localStat.mtime; 822 | const remoteMtime = new Date(latest.modified).getTime(); 823 | if (onlyNew && localMtime >= remoteMtime) { 824 | this.logWrite(`${filename}: Ours is newer than the backup. Skipping...`); 825 | continue; 826 | } 827 | } else { 828 | if (fileInfo.missing) { 829 | this.logWrite(`${filename}: is missing and not found in the vault. Skipping...`); 830 | continue; 831 | } 832 | } 833 | this.logWrite(`${filename}: will be restored from ${zipName}`); 834 | if (!zipFileMap.has(zipName)) { 835 | zipFileMap.set(zipName, []); 836 | } 837 | zipFileMap.get(zipName)?.push(filename); 838 | processFileCount++; 839 | 840 | // latestZipMap.set(filename, zipName); 841 | } 842 | if (processFileCount == 0 && deletingFiles.length == 0) { 843 | this.logMessage(`Nothing to restore`); 844 | return; 845 | } 846 | const detailFiles = `
847 | 848 | ${[...zipFileMap.entries()] 849 | .map((e) => `${e[1].map((ee) => `- ${ee} (${e[0]})`).join("\n")}\n`) 850 | .sort((a, b) => a.localeCompare(b)) 851 | .join("")} 852 | 853 | 854 |
`; 855 | const detailDeletedFiles = `
856 | 857 | ${deletingFiles.map((e) => `- ${e}`).join("\n")} 858 | 859 |
`; 860 | const deleteMessage = 861 | deleteMissing && deletingFiles.length > 0 862 | ? `And ${deletingFiles.length} files will be deleted.\n${detailDeletedFiles}\n` 863 | : ""; 864 | const message = `We have ${processFileCount} files to restore on ${zipFileMap.size} ZIPs. \n${detailFiles}\n${deleteMessage}Are you sure to proceed?`; 865 | const RESTORE_BUTTON = "Yes, restore them!"; 866 | const CANCEL = "Cancel"; 867 | if ( 868 | (await confirmWithMessage(this, "Restore Confirmation", message, [RESTORE_BUTTON, CANCEL], CANCEL)) != 869 | RESTORE_BUTTON 870 | ) { 871 | this.logMessage(`Cancelled`); 872 | return; 873 | } 874 | for (const [zipName, files] of zipFileMap) { 875 | this.logMessage(`Extracting ${zipName}...`); 876 | await this.extract(zipName, files, undefined, prefix); 877 | } 878 | // console.dir(zipFileMap); 879 | } 880 | async onload() { 881 | await this.loadSettings(); 882 | if ("backupFolder" in this.settings) { 883 | this.settings.backupFolderMobile = this.settings.backupFolder as string; 884 | delete this.settings.backupFolder; 885 | } 886 | this.app.workspace.onLayoutReady(() => this.onLayoutReady()); 887 | 888 | this.addCommand({ 889 | id: "a-find-from-backups", 890 | name: "Restore from backups", 891 | callback: async () => { 892 | const d = new RestoreDialog(this.app, this); 893 | d.open(); 894 | }, 895 | }); 896 | this.addCommand({ 897 | id: "find-from-backups-old", 898 | name: "Restore from backups (previous behaviour)", 899 | callback: async () => { 900 | await this.selectAndRestore(); 901 | }, 902 | }); 903 | 904 | this.addCommand({ 905 | id: "find-from-backups-dir", 906 | name: "Restore from backups per folder", 907 | callback: async () => { 908 | await this.selectAndRestoreFolder(); 909 | }, 910 | }); 911 | this.addCommand({ 912 | id: "b-create-diff-zip", 913 | name: "Create Differential Backup", 914 | callback: () => { 915 | this.createZip(true); 916 | }, 917 | }); 918 | this.addCommand({ 919 | id: "b-create-diff-zip-only-new", 920 | name: "Create Differential Backup Only Newer Files", 921 | callback: () => { 922 | this.createZip(true, [], true); 923 | }, 924 | }); 925 | this.addCommand({ 926 | id: "b-create-diff-zip-only-new-and-existing", 927 | name: "Create Non-Destructive Differential Backup", 928 | callback: () => { 929 | this.createZip(true, [], false, true); 930 | }, 931 | }); 932 | this.addCommand({ 933 | id: "b-create-diff-zip-only-new-and-existing-only-new", 934 | name: "Create Non-Destructive Differential Backup Only Newer Files", 935 | callback: () => { 936 | this.createZip(true, [], true, true); 937 | }, 938 | }); 939 | 940 | this.addCommand({ 941 | id: "vault-restore-from-backups-only-new", 942 | name: "Fetch all new files from the backups", 943 | callback: async () => { 944 | await this.restoreVault(true, false); 945 | }, 946 | }); 947 | this.addCommand({ 948 | id: "vault-restore-from-backups-with-deletion", 949 | name: "⚠ Restore Vault from backups and delete with deletion", 950 | callback: async () => { 951 | await this.restoreVault(false, true); 952 | }, 953 | }); 954 | this.addSettingTab(new DiffZipSettingTab(this.app, this)); 955 | } 956 | 957 | async loadSettings() { 958 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 959 | } 960 | 961 | async resetToC() { 962 | const toc = {} as FileInfos; 963 | const tocFilePath = this.backups.normalizePath(`${this.backupFolder}${this.sep}${InfoFile}`); 964 | // Update TOC 965 | if ( 966 | await this.backups.writeTOC( 967 | tocFilePath, 968 | toArrayBuffer(new TextEncoder().encode(`\`\`\`\n${stringifyYaml(toc)}\n\`\`\`\n`)) 969 | ) 970 | ) { 971 | this.logMessage(`Backup information has been reset`); 972 | } else { 973 | this.logMessage(`Backup information cannot reset`); 974 | } 975 | } 976 | 977 | async saveSettings() { 978 | await this.saveData(this.settings); 979 | } 980 | } 981 | 982 | function ellipsisMiddle(text: string, maxLength: number = 60) { 983 | if (text.length <= maxLength) { 984 | return text; 985 | } 986 | const ellipsis = "..."; 987 | const charsToShow = maxLength - ellipsis.length; 988 | const start = Math.ceil(charsToShow / 2); 989 | const end = text.length - Math.floor(charsToShow / 2); 990 | return text.slice(0, start) + ellipsis + text.slice(end); 991 | } 992 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "diffzip", 3 | "name": "Differential ZIP Backup", 4 | "version": "0.0.20", 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.20", 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.38" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Archive.ts: -------------------------------------------------------------------------------- 1 | import * as fflate from "fflate"; 2 | import { delay, promiseWithResolver } from "octagonal-wheels/promises"; 3 | import type { XByteArray } from "./types.ts"; 4 | 5 | /** 6 | * A class to archive files 7 | */ 8 | export class Archiver { 9 | _zipFile: fflate.Zip; 10 | _aborted: boolean = false; 11 | _output: XByteArray[] = []; 12 | _processedCount: number = 0; 13 | _processedLength: number = 0; 14 | _archivedCount: number = 0; 15 | _archiveSize: number = 0; 16 | 17 | progressReport(type: string) { 18 | // console.warn( 19 | // `Archiver: ${type} processed: ${this._processedCount} (${this._processedLength} bytes) ${this._archivedCount} (${this._archiveSize} bytes)` 20 | // ) 21 | } 22 | 23 | _zipFilePromise = promiseWithResolver(); 24 | get archivedZipFile(): Promise { 25 | return this._zipFilePromise.promise; 26 | } 27 | 28 | get currentSize(): number { 29 | return this._output.reduce((acc, val) => acc + val.length, 0); 30 | } 31 | 32 | constructor() { 33 | const zipFile = new fflate.Zip(async (error, dat: XByteArray, final) => this._onProgress(error, dat, final)); 34 | this._zipFile = zipFile; 35 | } 36 | 37 | _onProgress(err: fflate.FlateError | null, data: XByteArray, final: boolean) { 38 | if (err) return this._onError(err); 39 | if (data && data.length > 0) { 40 | this._output.push(data); 41 | this._archiveSize += data.length; 42 | } 43 | // No error 44 | this.progressReport("progress"); 45 | if (this._aborted) return this._onAborted(); 46 | if (final) void this._onFinalise(); 47 | } 48 | 49 | async _onFinalise(): Promise { 50 | this._zipFile.terminate(); 51 | const out = new Blob(this._output, { type: "application/zip" }); 52 | const result = new Uint8Array(await out.arrayBuffer()); 53 | this._zipFilePromise.resolve(result); 54 | } 55 | 56 | _onAborted() { 57 | this._zipFile.terminate(); 58 | this._zipFilePromise.reject(new Error("Aborted")); 59 | } 60 | 61 | _onError(err: fflate.FlateError): void { 62 | this._zipFile.terminate(); 63 | this._zipFilePromise.reject(err); 64 | } 65 | 66 | addTextFile(text: string, path: string, options?: { mtime?: number }): void { 67 | const binary = new TextEncoder().encode(text); 68 | this.addFile(binary, path, options); 69 | } 70 | 71 | addFileTask = Promise.resolve(); 72 | addFile(file: XByteArray, path: string, options?: { mtime?: number }, progress?: (processed: number, total: number, finished: boolean) => void): void { 73 | const fflateFile = new fflate.ZipDeflate(path, { level: 9 }); 74 | fflateFile.mtime = options?.mtime ?? Date.now(); 75 | const total = file.byteLength; 76 | let processed = 0; 77 | this.progressReport("add"); 78 | this._zipFile.add(fflateFile); 79 | const MAX_CHUNK_SIZE = 1024 * 1024; // 1MB 80 | const MIN_CHUNK_SIZE = 64 * 1024; // 64KB 81 | const div10 = Math.ceil(file.length / 10); 82 | const chunkSize = Math.max(Math.min(MAX_CHUNK_SIZE, div10), MIN_CHUNK_SIZE); 83 | this.addFileTask = this.addFileTask.then(async () => { 84 | for (let i = 0; i < file.length; i += chunkSize) { 85 | const chunk = file.slice(i, i + chunkSize); 86 | processed += chunk.byteLength; 87 | fflateFile.push(chunk, false); 88 | if (chunkSize > MIN_CHUNK_SIZE) { 89 | progress?.(processed, total, false); 90 | } 91 | await new Promise(res => setTimeout(res, 1)); 92 | } 93 | fflateFile.push(new Uint8Array(), true); 94 | progress?.(processed, total, true); 95 | return Promise.resolve(); 96 | }); 97 | } 98 | 99 | finalize() { 100 | this._zipFile.end(); 101 | return this.archivedZipFile; 102 | } 103 | } 104 | 105 | /** 106 | * A class to extract files from a zip archive 107 | */ 108 | export class Extractor { 109 | _zipFile: fflate.Unzip; 110 | _isFileShouldBeExtracted: (file: fflate.UnzipFile) => boolean | Promise; 111 | _onExtracted: (filename: string, content: XByteArray) => Promise; 112 | 113 | constructor(isFileShouldBeExtracted: Extractor["_isFileShouldBeExtracted"], callback: Extractor["_onExtracted"]) { 114 | const unzipper = new fflate.Unzip(); 115 | unzipper.register(fflate.UnzipInflate); 116 | this._zipFile = unzipper; 117 | this._isFileShouldBeExtracted = isFileShouldBeExtracted; 118 | this._onExtracted = callback; 119 | unzipper.onfile = async (file: fflate.UnzipFile) => { 120 | if (await this._isFileShouldBeExtracted(file)) { 121 | const data: XByteArray[] = []; 122 | file.ondata = async (err, dat: XByteArray, isFinal) => { 123 | if (err) { 124 | console.error("Error extracting file", err); 125 | return; 126 | } 127 | if (dat && dat.length > 0) data.push(dat); 128 | 129 | if (isFinal) { 130 | const total = new Blob(data, { type: "application/octet-stream" }); 131 | const result = new Uint8Array(await total.arrayBuffer()); 132 | await this._onExtracted(file.name, result); 133 | } 134 | }; 135 | file.start(); 136 | } 137 | }; 138 | } 139 | 140 | addZippedContent(data: XByteArray, isFinal = false) { 141 | this._zipFile.push(data, isFinal); 142 | } 143 | 144 | finalise() { 145 | this._zipFile.push(new Uint8Array(), true); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/CombinedFragment.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents a combination of multiple DocumentFragments. 3 | * Allows dynamic rebuilding and visibility checking of the combined fragment. 4 | */ 5 | export class CombinedFragment { 6 | /** 7 | * The combined DocumentFragment instance. 8 | */ 9 | _fragment: DocumentFragment; 10 | 11 | /** 12 | * Array of factory functions that generate DocumentFragments. 13 | */ 14 | _fragmentFactories: (() => DocumentFragment)[]; 15 | 16 | /** 17 | * Creates a new CombinedFragment from an array of fragment factory functions. 18 | * @param fragments - Array of functions returning DocumentFragment instances. 19 | */ 20 | constructor(fragments: (() => DocumentFragment)[]) { 21 | this._fragmentFactories = fragments; 22 | this._fragment = this.buildFragment(fragments); 23 | } 24 | 25 | /** 26 | * Builds a single DocumentFragment by appending the result of each factory function. 27 | * @param fragments - Array of functions returning DocumentFragment instances. 28 | * @returns The combined DocumentFragment. 29 | */ 30 | buildFragment(fragments: (() => DocumentFragment)[]) { 31 | const f = document.createDocumentFragment(); 32 | fragments.forEach(fragment => { 33 | f.appendChild(fragment()); 34 | }); 35 | return f; 36 | } 37 | 38 | /** 39 | * Rebuilds the combined DocumentFragment using the provided or existing factories. 40 | * @param fragments - Optional array of factory functions. Defaults to current factories. 41 | * @returns The rebuilt DocumentFragment. 42 | */ 43 | rebuildFragment(fragments: (() => DocumentFragment)[] = this._fragmentFactories) { 44 | this._fragmentFactories = fragments; 45 | this._fragment = this.buildFragment(fragments); 46 | return this._fragment; 47 | } 48 | 49 | /** 50 | * Gets the current combined DocumentFragment. 51 | */ 52 | get fragment() { 53 | return this._fragment; 54 | } 55 | 56 | /** 57 | * Determines if any child HTMLElement of the fragment is visible (isShown). 58 | */ 59 | get isVisible() { 60 | return Array.from(this._fragment.childNodes).some(e => { 61 | return e instanceof HTMLElement && e.isShown(); 62 | }); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/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.ts"; 4 | import { askSelectString } from "./dialog.ts"; 5 | import { S3Bucket } from "./StorageAccessor/S3Bucket.ts"; 6 | import { AutoBackupType } from "./types.ts"; 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("Max total source size in a single ZIP in MB") 244 | .setDesc("(0 to disabled) Limit the total size of files in a single ZIP file to better restore performance") 245 | .addText((text) => 246 | text 247 | .setPlaceholder("30") 248 | .setValue(this.plugin.settings.maxTotalSizeInZip + "") 249 | .onChange(async (value) => { 250 | this.plugin.settings.maxTotalSizeInZip = Number.parseInt(value); 251 | await this.plugin.saveSettings(); 252 | }) 253 | ); 254 | new Setting(containerEl) 255 | .setName("Perform all files over the max files") 256 | .setDesc( 257 | "Automatically process the remaining files, even if the number of files to be processed exceeds Max files." 258 | ) 259 | .addToggle((toggle) => 260 | toggle.setValue(this.plugin.settings.performNextBackupOnMaxFiles).onChange(async (value) => { 261 | this.plugin.settings.performNextBackupOnMaxFiles = value; 262 | await this.plugin.saveSettings(); 263 | }) 264 | ); 265 | 266 | new Setting(containerEl) 267 | .setName("Max size of each output ZIP file") 268 | .setDesc("(MB) Size to split the backup zip file. Unzipping requires 7z or other compatible tools.") 269 | .addText((text) => 270 | text 271 | .setPlaceholder("30") 272 | .setValue(this.plugin.settings.maxSize + "") 273 | .onChange(async (value) => { 274 | this.plugin.settings.maxSize = Number.parseInt(value); 275 | await this.plugin.saveSettings(); 276 | }) 277 | ); 278 | 279 | containerEl.createEl("h2", { text: "Misc" }); 280 | 281 | new Setting(containerEl) 282 | .setName("Reset Backup Information") 283 | .setDesc("After resetting, backup information will be lost.") 284 | .addButton((button) => 285 | button 286 | .setWarning() 287 | .setButtonText("Reset") 288 | .onClick(async () => { 289 | this.plugin.resetToC(); 290 | }) 291 | ); 292 | new Setting(containerEl) 293 | .setName("Encryption") 294 | .setDesc( 295 | "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]" 296 | ) 297 | .addText( 298 | (text) => 299 | (text 300 | .setPlaceholder("Passphrase") 301 | .setValue(this.plugin.settings.passphraseOfZip) 302 | .onChange(async (value) => { 303 | this.plugin.settings.passphraseOfZip = value; 304 | await this.plugin.saveSettings(); 305 | }).inputEl.type = "password") 306 | ); 307 | 308 | containerEl.createEl("h2", { text: "Tools" }); 309 | let passphrase = ""; 310 | new Setting(containerEl) 311 | .setName("Passphrase") 312 | .setDesc("You can encrypt the settings with a passphrase") 313 | .addText( 314 | (text) => 315 | (text 316 | .setPlaceholder("Passphrase") 317 | .setValue(passphrase) 318 | .onChange(async (value) => { 319 | passphrase = value; 320 | await this.plugin.saveSettings(); 321 | }).inputEl.type = "password") 322 | ); 323 | 324 | new Setting(containerEl) 325 | .setName("Copy setting to another device via URI") 326 | .setDesc("You can copy the settings to another device by URI") 327 | .addButton((button) => { 328 | button.setButtonText("Copy to Clipboard").onClick(async () => { 329 | const setting = JSON.stringify(this.plugin.settings); 330 | const encrypted = await encrypt(setting, passphrase, false); 331 | const uri = `obsidian://diffzip/settings?data=${encodeURIComponent(encrypted)}`; 332 | await navigator.clipboard.writeText(uri); 333 | new Notice("URI has been copied to the clipboard"); 334 | }); 335 | }); 336 | 337 | let copiedURI = ""; 338 | new Setting(containerEl) 339 | .setName("Paste setting from another device") 340 | .setDesc("You can paste the settings from another device by URI") 341 | .addText((text) => { 342 | text.setPlaceholder("obsidian://diffzip/settings?data=....") 343 | .setValue(copiedURI) 344 | .onChange(async (value) => { 345 | copiedURI = value; 346 | }); 347 | }) 348 | .addButton((button) => { 349 | button.setButtonText("Apply"); 350 | button.setWarning(); 351 | button.onClick(async () => { 352 | const uri = copiedURI; 353 | const data = decodeURIComponent(uri.split("?data=")[1]); 354 | try { 355 | const decrypted = await decrypt(data, passphrase, false); 356 | const settings = JSON.parse(decrypted); 357 | if ( 358 | (await askSelectString(this.app, "Are you sure to overwrite the settings?", [ 359 | "Yes", 360 | "No", 361 | ])) == "Yes" 362 | ) { 363 | Object.assign(this.plugin.settings, settings); 364 | await this.plugin.saveSettings(); 365 | this.display(); 366 | } else { 367 | new Notice("Cancelled"); 368 | } 369 | } catch (e) { 370 | new Notice("Failed to decrypt the settings"); 371 | console.warn(e); 372 | } 373 | }); 374 | }); 375 | } 376 | } 377 | -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /src/ProgressFragment.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents a UI fragment for displaying and controlling progress. 3 | * Provides properties and methods to update progress, label, note, and completion state. 4 | */ 5 | export class ProgressFragment { 6 | 7 | /** 8 | * The root DocumentFragment containing the progress UI. 9 | */ 10 | _fragment: DocumentFragment; 11 | /** 12 | * The HTMLProgressElement used to display progress. 13 | */ 14 | _progressEl?: HTMLProgressElement; 15 | /** 16 | * The total value for progress completion. 17 | */ 18 | _total: number = 100; 19 | /** 20 | * The current progress value. 21 | */ 22 | _value: number = 0; 23 | /** 24 | * The label text displayed above the progress bar. 25 | */ 26 | _titleText: string = ""; 27 | /** 28 | * The HTMLLabelElement for the progress label. 29 | */ 30 | _titleEl?: HTMLLabelElement; 31 | /** 32 | * The HTMLSpanElement for displaying a note below the progress bar. 33 | */ 34 | _noteEl?: HTMLSpanElement; 35 | /** 36 | * The HTMLSpanElement for displaying numeric progress status. 37 | */ 38 | _numericStatusEl?: HTMLSpanElement; 39 | /** 40 | * The wrapper div containing all progress UI elements. 41 | */ 42 | _wrapperEl?: HTMLDivElement; 43 | 44 | /** 45 | * The note text displayed below the progress bar. 46 | */ 47 | _noteText: string = ""; 48 | /** 49 | * Callback invoked when progress completes. 50 | */ 51 | _onComplete?: () => void; 52 | /** 53 | * Callback invoked when progress is cancelled. 54 | */ 55 | _onCancel?: () => void; 56 | /** 57 | * Callback invoked when the fragment is ready. 58 | */ 59 | _onReady?: (fragment: DocumentFragment) => void; 60 | /** 61 | * Callback invoked when progress changes. 62 | */ 63 | _onProgress?: () => void; 64 | 65 | /** 66 | * Indicates whether the progress has been cancelled. 67 | */ 68 | _isCancelled = false; 69 | 70 | /** 71 | * Indicates whether the progress UI is collapsed. 72 | */ 73 | _isCollapsed: boolean = false; 74 | 75 | /** 76 | * Returns true if the progress UI is currently shown. 77 | */ 78 | get isShown() { 79 | return this._wrapperEl?.isShown() ?? false; 80 | } 81 | /** 82 | * Gets or sets whether the progress UI is collapsed. 83 | */ 84 | get collapsed() { 85 | return this._isCollapsed; 86 | } 87 | set collapsed(value: boolean) { 88 | this._isCollapsed = value; 89 | if (this._wrapperEl) { 90 | this._wrapperEl.style.display = value ? "none" : "block"; 91 | } 92 | } 93 | 94 | /** 95 | * Gets or sets whether the progress has been cancelled. 96 | */ 97 | get isCancelled() { 98 | return this._isCancelled; 99 | } 100 | set isCancelled(value: boolean) { 101 | this._isCancelled = value; 102 | this.computeNumeric(); 103 | this.__onProgress(); 104 | } 105 | 106 | /** 107 | * Returns true if the progress has completed. 108 | */ 109 | get isCompleted() { 110 | return this.value != 0 && this._value >= this._total; 111 | } 112 | /** 113 | * Returns true if the progress has started. 114 | */ 115 | get isStarted() { 116 | return this.value != 0 && this._total != 0; 117 | } 118 | 119 | /** 120 | * Gets or sets the current progress value. 121 | */ 122 | get value() { 123 | return this._value; 124 | } 125 | 126 | /** 127 | * Gets or sets the maximum progress value. 128 | */ 129 | get total() { 130 | return this._total; 131 | } 132 | 133 | /** 134 | * Gets or sets the label text. 135 | */ 136 | get title() { 137 | return this._titleText; 138 | } 139 | 140 | 141 | set value(val: number) { 142 | this._value = val; 143 | if (this._progressEl) { 144 | this._progressEl.value = val; 145 | } 146 | this.computeNumeric(); 147 | if (this.isCompleted && this._onComplete) { 148 | setTimeout(() => this._onComplete?.(), 10); 149 | } 150 | this.__onProgress(); 151 | } 152 | set total(val: number) { 153 | this._total = val; 154 | if (this._progressEl) { 155 | this._progressEl.max = val; 156 | } 157 | this.computeNumeric(); 158 | this.__onProgress(); 159 | } 160 | 161 | set title(val: string) { 162 | this._titleText = val; 163 | if (this._titleEl) { 164 | this._titleEl.textContent = val; 165 | } 166 | this.computeMaxWidth(); 167 | this.__onProgress(); 168 | } 169 | 170 | /** 171 | * Gets or sets the note text. 172 | */ 173 | get note() { 174 | return this._noteText; 175 | } 176 | 177 | set note(val: string) { 178 | this._noteText = val; 179 | if (this._noteEl) { 180 | this._noteEl.textContent = val; 181 | } 182 | this.computeMaxWidth(); 183 | this.__onProgress(); 184 | } 185 | 186 | /** 187 | * Returns the root DocumentFragment for this progress UI. 188 | */ 189 | get fragment() { 190 | return this._fragment; 191 | } 192 | 193 | /** 194 | * Updates the numeric status display based on current progress. 195 | */ 196 | computeNumeric() { 197 | if (this._numericStatusEl) { 198 | if (this.isCancelled) { 199 | this._numericStatusEl.textContent = `- / -`; 200 | this.computeMaxWidth(); 201 | } 202 | if (this.isStarted) { 203 | this._numericStatusEl.textContent = `${this._value} / ${this._total}`; 204 | this.computeMaxWidth(); 205 | } else { 206 | this._numericStatusEl.textContent = ""; 207 | } 208 | } 209 | } 210 | 211 | /** 212 | * Internal flag indicating if properties are being applied. 213 | * @internal 214 | */ 215 | __isApplying = false; 216 | /** 217 | * Internal flag indicating if onProgress is being called. 218 | * @internal 219 | */ 220 | __isOnProgress = false; 221 | /** 222 | * Invokes the onProgress callback if set. 223 | * @internal 224 | */ 225 | __onProgress() { 226 | if (this.__isOnProgress) return; 227 | if (this.__isApplying) return; 228 | try { 229 | this.__isOnProgress = true; 230 | this._onProgress?.(); 231 | } finally { 232 | this.__isOnProgress = false; 233 | } 234 | } 235 | 236 | /** 237 | * Minimum width of the progress UI. 238 | */ 239 | minWidth = 200; 240 | /** 241 | * Minimum height of the progress UI. 242 | */ 243 | minHeight = 10; 244 | /** 245 | * Computes and sets the minimum width and height of the wrapper based on content. 246 | */ 247 | computeMaxWidth() { 248 | if (this._wrapperEl) { 249 | const wrapperRect = this._wrapperEl.getBoundingClientRect(); 250 | const wrapperW = wrapperRect.width; 251 | const wrapperH = wrapperRect.height; 252 | if (wrapperW > this.minWidth) { 253 | this.minWidth = wrapperW; 254 | this._wrapperEl.style.minWidth = `${this.minWidth}px`; 255 | } 256 | if (wrapperH > this.minHeight) { 257 | this.minHeight = wrapperH; 258 | this._wrapperEl.style.minHeight = `${this.minHeight}px`; 259 | } 260 | } else { 261 | console.warn(`Wrapper is not connected`); 262 | } 263 | } 264 | 265 | /** 266 | * Constructs a new ProgressFragment. 267 | * @param options - Initialisation options for value, total, title, and callbacks. 268 | */ 269 | constructor({ value = 0, total = 0, title = "", onComplete, onCancel, onReady, onProgress }: { 270 | value?: number; total?: number; title?: string; 271 | onComplete?: () => void; onCancel?: () => void; 272 | onReady?: (fragment: DocumentFragment) => void; 273 | onProgress?: () => void; 274 | 275 | }) { 276 | this._value = value ?? 0; 277 | this._total = total ?? 0; 278 | this._titleText = title ?? ""; 279 | this._onComplete = onComplete; 280 | this._onCancel = onCancel; 281 | this._onReady = onReady; 282 | this._onProgress = onProgress; 283 | this._fragment = this.constructFragment(); 284 | this.__isApplying = true; 285 | this.applyProperties(); 286 | this.__isApplying = false; 287 | } 288 | /** 289 | * Constructs the DocumentFragment containing the progress UI. 290 | * @returns The constructed DocumentFragment. 291 | */ 292 | constructFragment() { 293 | const f = document.createDocumentFragment(); 294 | const d = document.createElement("div"); 295 | d.style.minWidth = `${this.minWidth}px`; 296 | d.style.width = "100%"; 297 | d.style.minHeight = `${this.minHeight}px`; 298 | d.style.display = "flex"; 299 | d.style.flexDirection = "column"; 300 | const titleLine = document.createElement("div"); 301 | titleLine.style.marginTop = "4px"; 302 | const lbl = document.createElement("label"); 303 | this._titleEl = lbl; 304 | const numeric = document.createElement("span"); 305 | numeric.style.marginLeft = "auto"; 306 | this._numericStatusEl = numeric; 307 | titleLine.style.display = "flex"; 308 | titleLine.appendChild(lbl); 309 | titleLine.appendChild(numeric); 310 | d.appendChild(titleLine); 311 | const p = document.createElement("progress"); 312 | // p.style.flexGrow = "1"; 313 | p.style.width = "100%"; 314 | this._progressEl = p; 315 | this._noteEl = document.createElement("span"); 316 | this._noteEl.style.whiteSpace = "pre-wrap"; 317 | d.appendChild(p); 318 | d.appendChild(this._noteEl); 319 | f.appendChild(d); 320 | this._wrapperEl = d; 321 | return f; 322 | } 323 | /** 324 | * Applies the current property values to the UI elements. 325 | */ 326 | applyProperties() { 327 | this.title = this._titleText; 328 | this.total = this._total; 329 | this.value = this._value; 330 | this.note = this._noteText; 331 | 332 | } 333 | /** 334 | * Reconstructs the DocumentFragment and reapplies properties. 335 | * @returns The reconstructed DocumentFragment. 336 | */ 337 | reconstructFragment() { 338 | this.__isApplying = true; 339 | this._fragment = this.constructFragment(); 340 | this.applyProperties(); 341 | this.__isApplying = false; 342 | return this._fragment; 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /src/RestoreFileInfo.svelte: -------------------------------------------------------------------------------- 1 | 29 | 30 |
31 | 32 | {filename} 33 | {#if isFolder} 34 | ({relatedFiles.length}) 35 | {/if} 36 | 37 | 38 | {#if timeStamps.length === 0} 39 | No Timestamp 40 | {:else} 41 | 53 | {/if} 54 | 55 | 56 | {#if isFolder} 57 | 58 | {/if} 59 | 60 | 61 |
62 | 63 | 93 | -------------------------------------------------------------------------------- /src/RestoreFiles.svelte: -------------------------------------------------------------------------------- 1 | 67 | 68 |
69 | {#if files} 70 | {#each files as file (file)} 71 | 78 | {/each} 79 | {/if} 80 |
81 | -------------------------------------------------------------------------------- /src/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.ts"; 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 | -------------------------------------------------------------------------------- /src/StorageAccessor/DirectVault.ts: -------------------------------------------------------------------------------- 1 | import type { Stat } from "obsidian"; 2 | import { FileType, StorageAccessorTypes } from "../storage.ts"; 3 | import { StorageAccessor } from "./StorageAccessor.ts"; 4 | 5 | 6 | export class DirectVault extends StorageAccessor { 7 | type = StorageAccessorTypes.DIRECT; 8 | 9 | sep = "/"; // Always use / as separator on vault. 10 | 11 | async createFolder(absolutePath: string): Promise { 12 | await this.app.vault.adapter.mkdir(absolutePath); 13 | } 14 | 15 | async checkType(path: string): Promise { 16 | const existence = await this.app.vault.adapter.exists(path); 17 | if (!existence) return FileType.Missing; 18 | const stat = await this.app.vault.adapter.stat(path); 19 | if (stat && stat.type == "folder") return FileType.Folder; 20 | return FileType.File; 21 | } 22 | 23 | async _writeBinary(path: string, data: ArrayBuffer): Promise { 24 | try { 25 | await this.app.vault.adapter.writeBinary(path, data); 26 | return true; 27 | } catch (e) { 28 | console.error(e); 29 | return false; 30 | } 31 | } 32 | 33 | async _readBinary(path: string) { 34 | if (!(await this.isFileExists(path))) return false; 35 | return this.app.vault.adapter.readBinary(path); 36 | } 37 | 38 | async stat(path: string): Promise { 39 | const stat = await this.app.vault.adapter.stat(path); 40 | if (!stat) return false; 41 | return stat; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/StorageAccessor/ExternalVaultFilesystem.ts: -------------------------------------------------------------------------------- 1 | import type { Stat } from "obsidian"; 2 | import { type FsAPI, FileType, StorageAccessorTypes } from "../storage.ts"; 3 | import { StorageAccessor } from "./StorageAccessor.ts"; 4 | import { toArrayBuffer } from "../util.ts"; 5 | 6 | 7 | export class ExternalVaultFilesystem extends StorageAccessor { 8 | type = StorageAccessorTypes.EXTERNAL; 9 | 10 | get sep(): string { 11 | //@ts-ignore internal API 12 | return this.app.vault.adapter.path.sep; 13 | } 14 | get fsPromises(): FsAPI { 15 | //@ts-ignore internal API 16 | return this.app.vault.adapter.fsPromises; 17 | } 18 | 19 | async createFolder(absolutePath: string): Promise { 20 | await this.fsPromises.mkdir(absolutePath, { recursive: true }); 21 | } 22 | 23 | async ensureDirectory(fullPath: string) { 24 | const delimiter = this.sep; 25 | const pathElements = fullPath.split(delimiter); 26 | pathElements.pop(); 27 | const mkPath = pathElements.join(delimiter); 28 | return await this.createFolder(mkPath); 29 | } 30 | 31 | async _writeBinary(fullPath: string, data: ArrayBuffer) { 32 | try { 33 | await this.fsPromises.writeFile(fullPath, Buffer.from(data)); 34 | return true; 35 | } catch (e) { 36 | console.error(e); 37 | return false; 38 | } 39 | } 40 | 41 | async _readBinary(path: string): Promise { 42 | const buffer = await this.fsPromises.readFile(path) as Buffer; 43 | return toArrayBuffer(buffer.buffer) 44 | } 45 | 46 | async checkType(path: string): Promise { 47 | try { 48 | const stat = await this.fsPromises.stat(path); 49 | if (stat.isDirectory()) return FileType.Folder; 50 | if (stat.isFile()) return FileType.File; 51 | // If it is not file or folder, then it is missing. 52 | // This is not possible in normal cases. 53 | return FileType.Missing; 54 | } catch { 55 | return FileType.Missing; 56 | } 57 | } 58 | 59 | normalizePath(path: string): string { 60 | //@ts-ignore internal API 61 | const f = this.app.vault.adapter.path; 62 | //@ts-ignore internal API 63 | const basePath = this.app.vault.adapter.basePath; 64 | const normalizedPath = f.normalize(path); 65 | const result = f.resolve(basePath, normalizedPath); 66 | return result; 67 | } 68 | 69 | async stat(path: string): Promise { 70 | // 71 | // It is not used on external vault for `backup` accessing. If we want to use this for vaultAccess, uncomment and test this. 72 | // 73 | // const nPath = this.normalizePath(path); 74 | // const stat = await this.fsPromises.stat(nPath).catch(() => false as false); 75 | // if (!stat) return false; 76 | // return { 77 | // type: stat.isDirectory() ? "folder" : "file", 78 | // mtime: stat.mtime.getTime(), 79 | // ctime: stat.ctime.getTime(), 80 | // size: stat.size, 81 | // }; 82 | throw new Error("Unsupported operation."); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/StorageAccessor/NormalVault.ts: -------------------------------------------------------------------------------- 1 | import { TFile, TFolder, type Stat } from "obsidian"; 2 | import { FileType, StorageAccessorTypes } from "../storage.ts"; 3 | import { StorageAccessor } from "./StorageAccessor.ts"; 4 | 5 | 6 | export class NormalVault extends StorageAccessor { 7 | type = StorageAccessorTypes.NORMAL; 8 | 9 | sep = "/"; // Always use / as separator on vault. 10 | 11 | async createFolder(absolutePath: string): Promise { 12 | await this.app.vault.createFolder(absolutePath); 13 | } 14 | 15 | async checkType(path: string): Promise { 16 | const af = this.app.vault.getAbstractFileByPath(path); 17 | if (af == null) return FileType.Missing; 18 | if (af instanceof TFile) return FileType.File; 19 | if (af instanceof TFolder) return FileType.Folder; 20 | throw new Error("Unknown file type."); 21 | } 22 | 23 | async _writeBinary(path: string, data: ArrayBuffer): Promise { 24 | try { 25 | const af = this.app.vault.getAbstractFileByPath(path); 26 | if (af == null) { 27 | await this.app.vault.createBinary(path, data); 28 | return true; 29 | } 30 | if (af instanceof TFile) { 31 | await this.app.vault.modifyBinary(af, data); 32 | return true; 33 | } 34 | } catch (e) { 35 | console.error(e); 36 | return false; 37 | } 38 | throw new Error("Folder exists with the same name."); 39 | } 40 | 41 | async _readBinary(path: string) { 42 | if (!(await this.isFileExists(path))) return false; 43 | return this.app.vault.adapter.readBinary(path); 44 | } 45 | 46 | async stat(path: string): Promise { 47 | const af = this.app.vault.getAbstractFileByPath(path); 48 | if (af == null) return false; 49 | if (af instanceof TFile) { 50 | return { 51 | type: "file", 52 | mtime: af.stat.mtime, 53 | size: af.stat.size, 54 | ctime: af.stat.ctime, 55 | }; 56 | } else if (af instanceof TFolder) { 57 | return { 58 | type: "folder", 59 | mtime: 0, 60 | ctime: 0, 61 | size: 0, 62 | }; 63 | } 64 | throw new Error("Unknown file type."); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/StorageAccessor/S3Bucket.ts: -------------------------------------------------------------------------------- 1 | import { S3 } from "@aws-sdk/client-s3"; 2 | import type { Stat } from "obsidian"; 3 | import { ObsHttpHandler } from "../ObsHttpHandler.ts"; 4 | import { FileType, StorageAccessorTypes } from "../storage.ts"; 5 | import { StorageAccessor } from "./StorageAccessor.ts"; 6 | import { toArrayBuffer } from "../util.ts"; 7 | 8 | 9 | export class S3Bucket extends StorageAccessor { 10 | type = StorageAccessorTypes.S3; 11 | sep = "/"; 12 | 13 | createFolder(absolutePath: string): Promise { 14 | // S3 does not have folder concept. So, we don't need to create folder. 15 | return Promise.resolve(); 16 | } 17 | ensureDirectory(fullPath: string): Promise { 18 | return Promise.resolve(); 19 | } 20 | 21 | async checkType(path: string): Promise { 22 | const client = await this.getClient(); 23 | try { 24 | await client.headObject({ 25 | Bucket: this.settings.bucket, 26 | Key: path, 27 | }); 28 | return FileType.File; 29 | } catch { 30 | return FileType.Missing; 31 | } 32 | } 33 | 34 | async getClient() { 35 | const client = new S3({ 36 | endpoint: this.settings.endPoint, 37 | region: this.settings.region, 38 | forcePathStyle: true, 39 | credentials: { 40 | accessKeyId: this.settings.accessKey, 41 | secretAccessKey: this.settings.secretKey, 42 | }, 43 | requestHandler: this.settings.useCustomHttpHandler ? new ObsHttpHandler(undefined, undefined) : undefined, 44 | }); 45 | return client; 46 | } 47 | 48 | async _writeBinary(fullPath: string, data: ArrayBuffer) { 49 | const client = await this.getClient(); 50 | try { 51 | const r = await client.putObject({ 52 | Bucket: this.settings.bucket, 53 | Key: fullPath, 54 | Body: new Uint8Array(data), 55 | }); 56 | if (~~((r.$metadata.httpStatusCode ?? 500) / 100) == 2) { 57 | return true; 58 | } else { 59 | console.error(`Failed to write binary to ${fullPath} (response code:${r.$metadata.httpStatusCode}).`); 60 | } 61 | return false; 62 | } catch (e) { 63 | console.error(e); 64 | return false; 65 | } 66 | } 67 | 68 | async _readBinary(fullPath: string, preventCache = false) { 69 | const client = await this.getClient(); 70 | const result = await client.getObject({ 71 | Bucket: this.settings.bucket, 72 | Key: fullPath, 73 | ResponseCacheControl: preventCache ? "no-cache" : undefined 74 | }); 75 | if (!result.Body) return false; 76 | const resultByteArray = await result.Body.transformToByteArray() as Uint8Array; 77 | return toArrayBuffer(resultByteArray); 78 | } 79 | 80 | stat(path: string): Promise { 81 | throw new Error("Unsupported operation."); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/StorageAccessor/StorageAccessor.ts: -------------------------------------------------------------------------------- 1 | import { normalizePath, type Stat } from "obsidian"; 2 | import type DiffZipBackupPlugin from "../../main.ts"; 3 | import { type StorageAccessorType, FileType, decryptCompatOpenSSL, encryptCompatOpenSSL } from "../storage.ts"; 4 | import { toArrayBuffer } from "../util.ts"; 5 | 6 | 7 | export abstract class StorageAccessor { 8 | type: StorageAccessorType; 9 | abstract sep: string; 10 | public plugin: DiffZipBackupPlugin; 11 | get app() { 12 | return this.plugin.app; 13 | } 14 | get settings() { 15 | return this.plugin.settings; 16 | } 17 | public basePath: string; 18 | get rootPath() { 19 | if (this.basePath == "") return ""; 20 | return this.basePath + this.sep; 21 | } 22 | public isLocal: boolean = false; 23 | 24 | constructor(plugin: DiffZipBackupPlugin, basePath?: string, isLocal?: boolean) { 25 | this.basePath = basePath || ""; 26 | this.plugin = plugin; 27 | this.isLocal = isLocal || false; 28 | } 29 | 30 | abstract createFolder(absolutePath: string): Promise; 31 | abstract checkType(path: string): Promise; 32 | 33 | async isFolderExists(path: string): Promise { 34 | return (await this.checkType(path)) == FileType.Folder; 35 | } 36 | async isFileExists(path: string): Promise { 37 | return (await this.checkType(path)) == FileType.File; 38 | } 39 | async isExists(path: string): Promise { 40 | return (await this.checkType(path)) != FileType.Missing; 41 | } 42 | 43 | async readBinary(path: string, preventUseCache = false): Promise { 44 | const encryptedData = await this._readBinary(path, preventUseCache); 45 | if (encryptedData === false) return false; 46 | if (!this.isLocal && this.settings.passphraseOfZip) { 47 | return toArrayBuffer(await decryptCompatOpenSSL(new Uint8Array(encryptedData), this.settings.passphraseOfZip, 10000) as Uint8Array); 48 | } 49 | return encryptedData; 50 | } 51 | 52 | async readTOC(path: string): Promise { 53 | if (this.type != "normal") return await this.readBinary(path, true); 54 | return await this._readBinary(path, true); 55 | } 56 | 57 | async writeBinary(path: string, data: ArrayBuffer): Promise { 58 | let content = data; 59 | if (!this.isLocal && this.settings.passphraseOfZip) { 60 | content = toArrayBuffer(await encryptCompatOpenSSL(new Uint8Array(data), this.settings.passphraseOfZip, 10000) as Uint8Array); 61 | } 62 | await this.ensureDirectory(path); 63 | return await this._writeBinary(path, content); 64 | } 65 | 66 | async writeTOC(path: string, data: ArrayBuffer): Promise { 67 | if (this.type != "normal") return this.writeBinary(path, data); 68 | await this.ensureDirectory(path); 69 | return await this._writeBinary(path, data); 70 | } 71 | 72 | abstract _writeBinary(path: string, data: ArrayBuffer): Promise; 73 | abstract _readBinary(path: string, preventUseCache?: boolean): Promise; 74 | 75 | normalizePath(path: string): string { 76 | return normalizePath(path); 77 | } 78 | abstract stat(path: string): Promise; 79 | 80 | async ensureDirectory(fullPath: string) { 81 | const pathElements = (this.rootPath + fullPath).split(this.sep); 82 | pathElements.pop(); 83 | let c = ""; 84 | for (const v of pathElements) { 85 | c += v; 86 | const type = await this.checkType(c); 87 | if (type == FileType.File) { 88 | throw new Error("File exists with the same name."); 89 | } else if (type == FileType.Missing) { 90 | await this.createFolder(c); 91 | } 92 | c += this.sep; 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /src/storage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Abstract class for storage accessors and its implementations. 3 | */ 4 | import type DiffZipBackupPlugin from "../main.ts"; 5 | import type { promises } from "node:fs"; 6 | import { OpenSSLCompat } from "octagonal-wheels/encryption"; 7 | import { NormalVault } from "./StorageAccessor/NormalVault.ts"; 8 | import { DirectVault } from "./StorageAccessor/DirectVault.ts"; 9 | import { ExternalVaultFilesystem } from "./StorageAccessor/ExternalVaultFilesystem.ts"; 10 | import { S3Bucket } from "./StorageAccessor/S3Bucket.ts"; 11 | import type { StorageAccessor } from "./StorageAccessor/StorageAccessor.ts"; 12 | export const decryptCompatOpenSSL = OpenSSLCompat.CBC.decryptCBC; 13 | export const encryptCompatOpenSSL = OpenSSLCompat.CBC.encryptCBC; 14 | 15 | export enum FileType { 16 | "Missing", 17 | "File", 18 | "Folder", 19 | } 20 | 21 | export type FsAPI = { 22 | mkdir: typeof promises.mkdir; 23 | writeFile: typeof promises.writeFile; 24 | readFile: typeof promises.readFile; 25 | stat: typeof promises.stat; 26 | }; 27 | 28 | export const StorageAccessorTypes = { 29 | NORMAL: "normal", 30 | DIRECT: "direct", 31 | EXTERNAL: "external", 32 | S3: "s3", 33 | } as const; 34 | 35 | export type StorageAccessorType = typeof StorageAccessorTypes[keyof typeof StorageAccessorTypes]; 36 | 37 | export function getStorageTypeForBackupAccess(plugin: DiffZipBackupPlugin): StorageAccessorType { 38 | if (plugin.isDesktopMode) { 39 | return StorageAccessorTypes.EXTERNAL; 40 | } else if (plugin.settings.bucketEnabled) { 41 | return StorageAccessorTypes.S3; 42 | } else { 43 | return StorageAccessorTypes.DIRECT; 44 | } 45 | } 46 | export function getStorageTypeForVaultAccess(plugin: DiffZipBackupPlugin): StorageAccessorType { 47 | if (plugin.settings.includeHiddenFolder) { 48 | return StorageAccessorTypes.DIRECT; 49 | } 50 | return StorageAccessorTypes.NORMAL; 51 | } 52 | 53 | export function getStorageInstance( 54 | type: StorageAccessorType, 55 | plugin: DiffZipBackupPlugin, 56 | basePath?: string, 57 | isLocal?: boolean 58 | ): StorageAccessor { 59 | if (type == StorageAccessorTypes.EXTERNAL) { 60 | return new ExternalVaultFilesystem(plugin, basePath, isLocal); 61 | } else if (type == StorageAccessorTypes.S3) { 62 | return new S3Bucket(plugin, basePath, isLocal); 63 | } else if (type == StorageAccessorTypes.DIRECT) { 64 | return new DirectVault(plugin, basePath, isLocal); 65 | } else { 66 | return new NormalVault(plugin, basePath, isLocal); 67 | } 68 | } 69 | 70 | export function getStorageForVault(plugin: DiffZipBackupPlugin, basePath?: string): StorageAccessor { 71 | const type = getStorageTypeForVaultAccess(plugin); 72 | return getStorageInstance(type, plugin, basePath, true); 73 | } 74 | export function getStorageForBackup(plugin: DiffZipBackupPlugin, basePath?: string, isLocal?: boolean): StorageAccessor { 75 | const type = getStorageTypeForBackupAccess(plugin); 76 | return getStorageInstance(type, plugin, basePath, isLocal); 77 | } 78 | -------------------------------------------------------------------------------- /src/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 | maxTotalSizeInZip: number; 17 | performNextBackupOnMaxFiles: boolean; 18 | startBackupAtLaunch: boolean; 19 | startBackupAtLaunchType: AutoBackupType; 20 | includeHiddenFolder: boolean; 21 | desktopFolderEnabled: boolean; 22 | BackupFolderDesktop: string; 23 | bucketEnabled: boolean; 24 | 25 | endPoint: string; 26 | accessKey: string; 27 | secretKey: string; 28 | bucket: string; 29 | region: string; 30 | passphraseOfFiles: string; 31 | passphraseOfZip: string; 32 | useCustomHttpHandler: boolean; 33 | } 34 | export const DEFAULT_SETTINGS: DiffZipBackupSettings = { 35 | startBackupAtLaunch: false, 36 | startBackupAtLaunchType: AutoBackupType.ONLY_NEW_AND_EXISTING, 37 | backupFolderMobile: "backup", 38 | BackupFolderDesktop: "c:\\temp\\backup", 39 | backupFolderBucket: "backup", 40 | restoreFolder: "restored", 41 | includeHiddenFolder: false, 42 | maxSize: 30, 43 | maxTotalSizeInZip: 30, 44 | desktopFolderEnabled: false, 45 | bucketEnabled: false, 46 | endPoint: "", 47 | accessKey: "", 48 | secretKey: "", 49 | region: "", 50 | bucket: "diffzip", 51 | maxFilesInZip: 100, 52 | performNextBackupOnMaxFiles: true, 53 | useCustomHttpHandler: false, 54 | passphraseOfFiles: "", 55 | passphraseOfZip: "", 56 | }; 57 | export type FileInfo = { 58 | filename: string; 59 | digest: string; 60 | history: { zipName: string; modified: string; missing?: boolean; processed?: number; digest: string }[]; 61 | mtime: number; 62 | processed?: number; 63 | missing?: boolean; 64 | }; 65 | export type FileInfos = Record; 66 | export type NoticeWithTimer = { 67 | notice: Notice; 68 | timer?: ReturnType; 69 | }; 70 | 71 | 72 | export type XByteArray = Uint8Array; 73 | export type XDataView = DataView; -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import type { XByteArray, XDataView } from "./types.ts"; 2 | 3 | export function* pieces(source: XByteArray, chunkSize: number): Generator, void, void> { 4 | let offset = 0; 5 | while (offset < source.length) { 6 | yield source.slice(offset, offset + chunkSize) as Uint8Array; 7 | offset += chunkSize; 8 | } 9 | } 10 | export async function computeDigest(data: XByteArray) { 11 | const hashBuffer = await crypto.subtle.digest("SHA-256", data); 12 | const hashArray = Array.from(new Uint8Array(hashBuffer)); 13 | const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); 14 | return hashHex; 15 | } 16 | 17 | export function toArrayBuffer(arr: Uint8Array | ArrayBuffer | DataView): ArrayBuffer { 18 | if (arr instanceof Uint8Array) { 19 | return arr.buffer; 20 | } 21 | if (arr instanceof DataView) { 22 | return arr.buffer; 23 | } 24 | 25 | return arr; 26 | } -------------------------------------------------------------------------------- /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 | "allowImportingTsExtensions": true, 16 | "noEmit": true, 17 | "lib": ["DOM", "ES5", "ES6", "ES7"] 18 | }, 19 | "include": ["**/*.ts"] 20 | } 21 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------