mirror of
https://github.com/vrtmrz/diffzip.git
synced 2026-07-22 09:40:32 +00:00
feat: Lightweight Sync
fixed: new adjust to Community Lint
This commit is contained in:
parent
bcc7cf433e
commit
6afc7efa7b
34 changed files with 9259 additions and 4948 deletions
|
|
@ -1,3 +0,0 @@
|
|||
node_modules/
|
||||
main.js
|
||||
data.json
|
||||
23
.eslintrc
23
.eslintrc
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"env": { "node": true },
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"@typescript-eslint/no-empty-function": "off"
|
||||
}
|
||||
}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -20,3 +20,7 @@ data.json
|
|||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
coverage_out
|
||||
coverage_dir
|
||||
# env
|
||||
.env
|
||||
16
README.md
16
README.md
|
|
@ -24,10 +24,17 @@ We can store all the files which have been modified, into a ZIP file.
|
|||
4. Select the place to save the restored file.
|
||||
5. We got an old file.
|
||||
|
||||
### Selective Apply a file (Lightweight Synchronisation)
|
||||
1. Perform `Selective Apply Remote Backup (Check and Mirror)` from the command palette.
|
||||
2. Select the file you want to apply.
|
||||
3. Perform `Apply` to apply the differences.
|
||||
### Selective Sync (Lightweight Synchronisation)
|
||||
This is now a practical sync workflow, not only a one-way mirror.
|
||||
We can decide action per file as `None`, `Fetch` (take remote to local), or `Send` (treat current local as source and create new backup entries).
|
||||
`Fetch` is executed first, then `Send` is executed. If any fetch operation fails, send phase is stopped to keep consistency.
|
||||
|
||||
When send is selected, files are grouped into multiple ZIPs while respecting both limits (`Max files in a single zip` and `Max total source size in a single ZIP in MB`).
|
||||
The TOC is updated sequentially per committed ZIP. If TOC update fails, just-created ZIP files are rolled back as much as possible.
|
||||
|
||||
1. Perform `Selective Sync Remote Backup` from the command palette.
|
||||
2. Select each file action (`None`, `Fetch`, or `Send`).
|
||||
3. Perform `Apply` to run the selected sync operations.
|
||||
|
||||
## Settings
|
||||
|
||||
|
|
@ -39,6 +46,7 @@ We can store all the files which have been modified, into a ZIP file.
|
|||
| Start backup at launch | When the plug-in has been loaded, Differential backup will be created automatically. |
|
||||
| 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. |
|
||||
| Include hidden folder | Backup also the configurations, plugins, themes, and, snippets. |
|
||||
| Default destructive sync actions | On selective sync screen, when enabled, `Delete` defaults to `Fetch` and `Extra (Delete)` defaults to `Send`. |
|
||||
| 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. |
|
||||
| Restore folder | The folder which restored files will be stored. |
|
||||
| Max files in a single zip | How many files are stored in a single ZIP file. |
|
||||
|
|
|
|||
6
deno.json
Normal file
6
deno.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"tasks": {
|
||||
"test": "deno test --allow-all src",
|
||||
"test:coverage": "deno test --allow-all --coverage=coverage_out src && deno coverage coverage_out --include=\"src/\" --detailed"
|
||||
}
|
||||
}
|
||||
28
deploy-test-vaults.mjs
Normal file
28
deploy-test-vaults.mjs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { cpSync, mkdirSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
// TEST_VAULT_PATHS is loaded via --env-file=.env (Node.js 20.6+)
|
||||
// Multiple paths can be separated by semicolons.
|
||||
const raw = process.env.TEST_VAULT_PATHS ?? "";
|
||||
const paths = raw
|
||||
.split(";")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (paths.length === 0) {
|
||||
console.error("TEST_VAULT_PATHS is empty");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const FILES = ["main.js", "manifest.json", "styles.css"];
|
||||
|
||||
for (const dest of paths) {
|
||||
const destResolved = resolve(dest);
|
||||
mkdirSync(destResolved, { recursive: true });
|
||||
for (const file of FILES) {
|
||||
cpSync(file, `${destResolved}/${file}`);
|
||||
console.log(` copied ${file} -> ${destResolved}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDeployed to ${paths.length} vault(s).`);
|
||||
57
eslint.config.mjs
Normal file
57
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import tsParser from "@typescript-eslint/parser";
|
||||
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
import globals from "globals";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import * as sveltePlugin from "eslint-plugin-svelte";
|
||||
export default defineConfig([
|
||||
globalIgnores([
|
||||
"node_modules",
|
||||
"data.json",
|
||||
"dist",
|
||||
"esbuild.config.mjs",
|
||||
"eslint.config.js",
|
||||
"version-bump.mjs",
|
||||
"versions.json",
|
||||
"main.js",
|
||||
"**/*.test.ts",
|
||||
]),
|
||||
...sveltePlugin.configs["flat/base"],
|
||||
...obsidianmd.configs.recommended,
|
||||
{
|
||||
files: ["**/*.ts"],
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
// projectService: {
|
||||
// allowDefaultProject: ["eslint.config.js", "manifest.json"],
|
||||
// },
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
extraFileExtensions: [".json"],
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"obsidianmd/no-plugin-as-component": "off", // Temporary
|
||||
"obsidianmd/rule-custom-message": "off", // Temporary
|
||||
"obsidianmd/ui/sentence-case": "off", // Temporary
|
||||
"obsidianmd/no-static-styles-assignment": "warn", // Temporary
|
||||
"obsidianmd/settings-tab/no-manual-html-headings": "warn", // Temporary
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.svelte"],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
|
||||
"obsidianmd/no-plugin-as-component": "off", // Temporary
|
||||
},
|
||||
},
|
||||
]);
|
||||
72
main.ts
72
main.ts
|
|
@ -31,7 +31,7 @@ export default class DiffZipBackupPlugin extends Plugin {
|
|||
|
||||
get isMobile(): boolean {
|
||||
// @ts-ignore
|
||||
return this.app.isMobile;
|
||||
return !!this.app.isMobile;
|
||||
}
|
||||
get isDesktopMode(): boolean {
|
||||
return this.settings.desktopFolderEnabled && !this.isMobile;
|
||||
|
|
@ -61,7 +61,7 @@ export default class DiffZipBackupPlugin extends Plugin {
|
|||
|
||||
get sep(): string {
|
||||
//@ts-ignore
|
||||
return this.isDesktopMode ? this.app.vault.adapter.path.sep : "/";
|
||||
return (this.isDesktopMode ? this.app.vault.adapter.path.sep : "/") as string;
|
||||
}
|
||||
|
||||
messages = {} as Record<string, NoticeWithTimer>;
|
||||
|
|
@ -139,7 +139,7 @@ export default class DiffZipBackupPlugin extends Plugin {
|
|||
return {};
|
||||
}
|
||||
const tocStr = new TextDecoder().decode(tocBin);
|
||||
toc = parseYaml(tocStr.replace(/^```$/gm, ""));
|
||||
toc = parseYaml(tocStr.replace(/^```$/gm, "")) as FileInfos;
|
||||
if (toc == null) {
|
||||
this.logMessage(`PARSE ERROR: Could not parse Backup information`, "proc-index");
|
||||
toc = {};
|
||||
|
|
@ -171,7 +171,7 @@ export default class DiffZipBackupPlugin extends Plugin {
|
|||
value: 0,
|
||||
total: 0,
|
||||
onComplete: () => {
|
||||
setTimeout(() => {
|
||||
window.setTimeout(() => {
|
||||
notice.hide();
|
||||
}, 1000);
|
||||
},
|
||||
|
|
@ -197,7 +197,7 @@ export default class DiffZipBackupPlugin extends Plugin {
|
|||
// Find missing files
|
||||
let missingFiles = 0;
|
||||
let progressNotice: Notice | undefined;
|
||||
let onProgress = () => {};
|
||||
let onProgress = () => { };
|
||||
const fragmentOption = {
|
||||
total: 0,
|
||||
onComplete: () => onCloseProgress(),
|
||||
|
|
@ -251,7 +251,7 @@ export default class DiffZipBackupPlugin extends Plugin {
|
|||
uploadingProgress,
|
||||
].every((e) => e.isCompleted || e.isCancelled)
|
||||
) {
|
||||
setTimeout(() => {
|
||||
window.setTimeout(() => {
|
||||
progressNotice?.hide();
|
||||
progressNotice = undefined;
|
||||
}, 3000);
|
||||
|
|
@ -446,8 +446,8 @@ export default class DiffZipBackupPlugin extends Plugin {
|
|||
log(`Backup information has been updated`, key);
|
||||
if (hasExtra && this.settings.performNextBackupOnMaxFiles) {
|
||||
checkingProgress.isCancelled = true;
|
||||
setTimeout(() => {
|
||||
this.createZip(verbosity, [...skippableFiles, ...processedFiles], onlyNew, skipDeleted);
|
||||
window.setTimeout(() => {
|
||||
void this.createZip(verbosity, [...skippableFiles, ...processedFiles], onlyNew, skipDeleted);
|
||||
}, 10);
|
||||
} else {
|
||||
this.logMessage(
|
||||
|
|
@ -526,7 +526,7 @@ export default class DiffZipBackupPlugin extends Plugin {
|
|||
return;
|
||||
}
|
||||
const chunks = pieces(new Uint8Array(binary), size);
|
||||
for await (const chunk of chunks) {
|
||||
for (const chunk of chunks) {
|
||||
extractor.addZippedContent(chunk);
|
||||
}
|
||||
}
|
||||
|
|
@ -566,10 +566,10 @@ export default class DiffZipBackupPlugin extends Plugin {
|
|||
howToRestore == RESTORE_OVERWRITE
|
||||
? selected
|
||||
: howToRestore == RESTORE_TO_RESTORE_FOLDER
|
||||
? this.vaultAccess.normalizePath(`${this.settings.restoreFolder}${this.sep}${selected}`)
|
||||
: howToRestore == RESTORE_WITH_SUFFIX
|
||||
? `${selectedWithoutExt}-${suffix}.${ext}`
|
||||
: "";
|
||||
? this.vaultAccess.normalizePath(`${this.settings.restoreFolder}${this.sep}${selected}`)
|
||||
: howToRestore == RESTORE_WITH_SUFFIX
|
||||
? `${selectedWithoutExt}-${suffix}.${ext}`
|
||||
: "";
|
||||
if (!restoreAs) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -730,10 +730,10 @@ export default class DiffZipBackupPlugin extends Plugin {
|
|||
howToRestore == RESTORE_OVERWRITE
|
||||
? selected
|
||||
: howToRestore == RESTORE_TO_RESTORE_FOLDER
|
||||
? this.vaultAccess.normalizePath(`${this.settings.restoreFolder}${this.sep}${selected}`)
|
||||
: howToRestore == RESTORE_WITH_SUFFIX
|
||||
? `${selectedWithoutExt}-${suffix}.${ext}`
|
||||
: "";
|
||||
? this.vaultAccess.normalizePath(`${this.settings.restoreFolder}${this.sep}${selected}`)
|
||||
: howToRestore == RESTORE_WITH_SUFFIX
|
||||
? `${selectedWithoutExt}-${suffix}.${ext}`
|
||||
: "";
|
||||
if (!restoreAs) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -750,7 +750,8 @@ export default class DiffZipBackupPlugin extends Plugin {
|
|||
this.settings.startBackupAtLaunchType == AutoBackupType.ONLY_NEW ||
|
||||
this.settings.startBackupAtLaunchType == AutoBackupType.ONLY_NEW_AND_EXISTING;
|
||||
const skipDeleted = this.settings.startBackupAtLaunchType == AutoBackupType.ONLY_NEW_AND_EXISTING;
|
||||
this.createZip(false, [], onlyNew, skipDeleted);
|
||||
// Fire and forget, no need to await
|
||||
void this.createZip(false, [], onlyNew, skipDeleted);
|
||||
}
|
||||
}
|
||||
// onunload(): void {
|
||||
|
|
@ -848,9 +849,9 @@ export default class DiffZipBackupPlugin extends Plugin {
|
|||
const detailFiles = `<details>
|
||||
|
||||
${[...zipFileMap.entries()]
|
||||
.map((e) => `${e[1].map((ee) => `- ${ee} (${e[0]})`).join("\n")}\n`)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.join("")}
|
||||
.map((e) => `${e[1].map((ee) => `- ${ee} (${e[0]})`).join("\n")}\n`)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.join("")}
|
||||
|
||||
|
||||
</details>`;
|
||||
|
|
@ -913,29 +914,29 @@ ${deletingFiles.map((e) => `- ${e}`).join("\n")}
|
|||
this.addCommand({
|
||||
id: "b-create-diff-zip",
|
||||
name: "Create Differential Backup",
|
||||
callback: () => {
|
||||
this.createZip(true);
|
||||
callback: async () => {
|
||||
await this.createZip(true);
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "b-create-diff-zip-only-new",
|
||||
name: "Create Differential Backup Only Newer Files",
|
||||
callback: () => {
|
||||
this.createZip(true, [], true);
|
||||
callback: async () => {
|
||||
await this.createZip(true, [], true);
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "b-create-diff-zip-only-new-and-existing",
|
||||
name: "Create Non-Destructive Differential Backup",
|
||||
callback: () => {
|
||||
this.createZip(true, [], false, true);
|
||||
callback: async () => {
|
||||
await this.createZip(true, [], false, true);
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "b-create-diff-zip-only-new-and-existing-only-new",
|
||||
name: "Create Non-Destructive Differential Backup Only Newer Files",
|
||||
callback: () => {
|
||||
this.createZip(true, [], true, true);
|
||||
callback: async () => {
|
||||
await this.createZip(true, [], true, true);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -954,14 +955,11 @@ ${deletingFiles.map((e) => `- ${e}`).join("\n")}
|
|||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "diffzip-check-and-mirror-remote",
|
||||
name: "Selective Apply Remote Backup (Check and Mirror)",
|
||||
callback: async () => {
|
||||
id: "check-and-mirror-remote",
|
||||
name: "Selective Sync Remote Backup",
|
||||
callback: () => {
|
||||
const storageType = getStorageTypeForBackupAccess(this);
|
||||
if (
|
||||
storageType !== StorageAccessorTypes.S3 &&
|
||||
storageType !== StorageAccessorTypes.EXTERNAL
|
||||
) {
|
||||
if (storageType !== StorageAccessorTypes.S3 && storageType !== StorageAccessorTypes.EXTERNAL) {
|
||||
new Notice(
|
||||
"Remote storage is not configured. Please enable S3 or Desktop external folder in settings."
|
||||
);
|
||||
|
|
@ -975,7 +973,7 @@ ${deletingFiles.map((e) => `- ${e}`).join("\n")}
|
|||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()) as DiffZipBackupSettings;
|
||||
}
|
||||
|
||||
async resetToC() {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"id": "diffzip",
|
||||
"name": "Differential ZIP Backup",
|
||||
"version": "0.0.21",
|
||||
"minAppVersion": "0.15.0",
|
||||
"minAppVersion": "1.4.10",
|
||||
"description": "Back our vault up with lesser storage.",
|
||||
"author": "vorotamoroz",
|
||||
"authorUrl": "https://github.com/vrtmrz",
|
||||
|
|
|
|||
12637
package-lock.json
generated
12637
package-lock.json
generated
File diff suppressed because it is too large
Load diff
85
package.json
85
package.json
|
|
@ -1,42 +1,47 @@
|
|||
{
|
||||
"name": "diffzip",
|
||||
"version": "0.0.21",
|
||||
"description": "Differential ZIP Backup",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||
"pretty": "npm run prettyNoWrite -- --write --log-level error",
|
||||
"prettyCheck": "npm run prettyNoWrite -- --check",
|
||||
"prettyNoWrite": "prettier --config ./.prettierrc \"*.ts\" "
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "^3.726.1",
|
||||
"@smithy/fetch-http-handler": "^5.0.1",
|
||||
"@smithy/protocol-http": "^5.0.1",
|
||||
"@smithy/querystring-builder": "^4.0.1",
|
||||
"@types/node": "^22.10.6",
|
||||
"@typescript-eslint/eslint-plugin": "8.20.0",
|
||||
"@typescript-eslint/parser": "8.20.0",
|
||||
"builtin-modules": "4.0.0",
|
||||
"esbuild": "0.24.2",
|
||||
"obsidian": "^1.8.7",
|
||||
"tslib": "2.8.1",
|
||||
"typescript": "5.7.3",
|
||||
"@tsconfig/svelte": "^5.0.4",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
"esbuild-svelte": "^0.9.0",
|
||||
"svelte": "^5.1.15",
|
||||
"svelte-check": "^4.0.7",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"prettier": "^3.5.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2",
|
||||
"octagonal-wheels": "^0.1.38"
|
||||
}
|
||||
"name": "diffzip",
|
||||
"version": "0.0.21",
|
||||
"description": "Differential ZIP Backup",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"deploy:test": "node --env-file=.env deploy-test-vaults.mjs",
|
||||
"build:deploy": "npm run build && npm run deploy:test",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||
"pretty": "npm run prettyNoWrite -- --write --log-level error",
|
||||
"prettyCheck": "npm run prettyNoWrite -- --check",
|
||||
"prettyNoWrite": "prettier --config ./.prettierrc \"*.ts\" ",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "^3.726.1",
|
||||
"@smithy/fetch-http-handler": "^5.0.1",
|
||||
"@smithy/protocol-http": "^5.0.1",
|
||||
"@smithy/querystring-builder": "^4.0.1",
|
||||
"@tsconfig/svelte": "^5.0.4",
|
||||
"@types/node": "^22.19.19",
|
||||
"@typescript-eslint/eslint-plugin": "8.56.1",
|
||||
"@typescript-eslint/parser": "^8.56.1",
|
||||
"builtin-modules": "4.0.0",
|
||||
"esbuild": "0.24.2",
|
||||
"esbuild-svelte": "^0.9.0",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-plugin-obsidianmd": "^0.3.0",
|
||||
"eslint-plugin-svelte": "^3.15.0",
|
||||
"obsidian": "^1.8.7",
|
||||
"prettier": "^3.5.3",
|
||||
"svelte": "^5.1.15",
|
||||
"svelte-check": "^4.0.7",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"tslib": "2.8.1",
|
||||
"typescript": "5.7.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2",
|
||||
"octagonal-wheels": "^0.1.38"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
165
src/Archive.test.ts
Normal file
165
src/Archive.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// Polyfill: Obsidian provides `window`, but Deno does not.
|
||||
// Archive.ts uses `window.setTimeout`, so we patch it here before importing.
|
||||
if (typeof window === "undefined") {
|
||||
(globalThis as unknown as Record<string, unknown>).window = globalThis;
|
||||
}
|
||||
|
||||
import { Archiver, Extractor } from "./Archive.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test: (name: string, fn: () => void | Promise<void>) => void;
|
||||
};
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEquals<T>(actual: T, expected: T, message: string) {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(`${message}\nactual=${JSON.stringify(actual)}\nexpected=${JSON.stringify(expected)}`);
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("Archiver + Extractor: round-trip a single text file", async () => {
|
||||
const archiver = new Archiver();
|
||||
archiver.addTextFile("hello, world", "note.md");
|
||||
const zipData = await archiver.finalize();
|
||||
|
||||
const extracted: Record<string, string> = {};
|
||||
const extractor = new Extractor(
|
||||
() => true,
|
||||
async (filename, content) => {
|
||||
extracted[filename] = new TextDecoder().decode(content);
|
||||
},
|
||||
);
|
||||
extractor.addZippedContent(zipData, true);
|
||||
// Give async callbacks time to settle
|
||||
await new Promise((res) => setTimeout(res, 100));
|
||||
|
||||
assertEquals(extracted["note.md"], "hello, world", "Extracted content must match original");
|
||||
});
|
||||
|
||||
Deno.test("Archiver + Extractor: round-trip multiple files", async () => {
|
||||
const files: Record<string, string> = {
|
||||
"a.md": "alpha",
|
||||
"b.md": "beta",
|
||||
"c.md": "gamma",
|
||||
};
|
||||
|
||||
const archiver = new Archiver();
|
||||
for (const [path, text] of Object.entries(files)) {
|
||||
archiver.addTextFile(text, path);
|
||||
}
|
||||
const zipData = await archiver.finalize();
|
||||
|
||||
const extracted: Record<string, string> = {};
|
||||
const extractor = new Extractor(
|
||||
() => true,
|
||||
async (filename, content) => {
|
||||
extracted[filename] = new TextDecoder().decode(content);
|
||||
},
|
||||
);
|
||||
extractor.addZippedContent(zipData, true);
|
||||
await new Promise((res) => setTimeout(res, 100));
|
||||
|
||||
for (const [path, text] of Object.entries(files)) {
|
||||
assertEquals(extracted[path], text, `Extracted content of ${path} must match original`);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Extractor: filter function skips unwanted files", async () => {
|
||||
const archiver = new Archiver();
|
||||
archiver.addTextFile("keep me", "keep.md");
|
||||
archiver.addTextFile("skip me", "skip.md");
|
||||
const zipData = await archiver.finalize();
|
||||
|
||||
const extracted: Record<string, string> = {};
|
||||
const extractor = new Extractor(
|
||||
(file) => file.name === "keep.md",
|
||||
async (filename, content) => {
|
||||
extracted[filename] = new TextDecoder().decode(content);
|
||||
},
|
||||
);
|
||||
extractor.addZippedContent(zipData, true);
|
||||
await new Promise((res) => setTimeout(res, 100));
|
||||
|
||||
assert("keep.md" in extracted, "keep.md must be extracted");
|
||||
assert(!("skip.md" in extracted), "skip.md must be skipped");
|
||||
});
|
||||
|
||||
Deno.test("Archiver: currentSize grows as files are added", async () => {
|
||||
const archiver = new Archiver();
|
||||
const before = archiver.currentSize;
|
||||
archiver.addTextFile("some content", "file.md");
|
||||
// finalize to flush all data into _output
|
||||
await archiver.finalize();
|
||||
const after = archiver.currentSize;
|
||||
|
||||
assert(after > before, "currentSize must increase after adding a file");
|
||||
});
|
||||
|
||||
// chunkSize > MIN_CHUNK_SIZE (64KB) when file.length > 640KB.
|
||||
// Use 800KB to ensure multi-chunk path and progress callback are exercised.
|
||||
Deno.test("Archiver: large file triggers multi-chunk path and progress callback", async () => {
|
||||
const SIZE = 800 * 1024; // 800KB
|
||||
const original = new Uint8Array(SIZE);
|
||||
// Fill with a recognisable pattern so round-trip can be verified
|
||||
for (let i = 0; i < SIZE; i++) original[i] = i & 0xff;
|
||||
|
||||
const progressCalls: Array<{ processed: number; total: number; finished: boolean }> = [];
|
||||
const archiver = new Archiver();
|
||||
archiver.addFile(original, "large.bin", {}, (processed, total, finished) => {
|
||||
progressCalls.push({ processed, total, finished });
|
||||
});
|
||||
const zipData = await archiver.finalize();
|
||||
|
||||
// Progress callback must have been called at least once during chunking
|
||||
assert(progressCalls.length > 0, "progress callback must be called for large files");
|
||||
// The final call must mark finished=true
|
||||
assert(progressCalls.at(-1)!.finished, "last progress call must have finished=true");
|
||||
// Processed bytes must equal total at the end
|
||||
assertEquals(progressCalls.at(-1)!.processed, SIZE, "processed must equal total size on finish");
|
||||
|
||||
// Round-trip verification
|
||||
const extracted: Record<string, Uint8Array> = {};
|
||||
const extractor = new Extractor(
|
||||
() => true,
|
||||
async (filename, content) => {
|
||||
extracted[filename] = content;
|
||||
},
|
||||
);
|
||||
extractor.addZippedContent(zipData, true);
|
||||
await new Promise((res) => setTimeout(res, 500));
|
||||
|
||||
assert("large.bin" in extracted, "large.bin must be extracted");
|
||||
assertEquals(extracted["large.bin"].length, SIZE, "Extracted size must match original");
|
||||
assertEquals(extracted["large.bin"][0], original[0], "First byte must match");
|
||||
assertEquals(extracted["large.bin"][SIZE - 1], original[SIZE - 1], "Last byte must match");
|
||||
});
|
||||
|
||||
Deno.test("Extractor: finalise() correctly ends streamed zip input", async () => {
|
||||
// Build a zip first
|
||||
const archiver = new Archiver();
|
||||
archiver.addTextFile("streamed content", "stream.md");
|
||||
const zipData = await archiver.finalize();
|
||||
|
||||
const extracted: Record<string, string> = {};
|
||||
const extractor = new Extractor(
|
||||
() => true,
|
||||
async (filename, content) => {
|
||||
extracted[filename] = new TextDecoder().decode(content);
|
||||
},
|
||||
);
|
||||
|
||||
// Feed zip data in two chunks WITHOUT isFinal, then call finalise() separately
|
||||
const half = Math.floor(zipData.length / 2);
|
||||
extractor.addZippedContent(zipData.slice(0, half), false);
|
||||
extractor.addZippedContent(zipData.slice(half), false);
|
||||
extractor.finalise();
|
||||
|
||||
await new Promise((res) => setTimeout(res, 200));
|
||||
|
||||
assertEquals(extracted["stream.md"], "streamed content", "Streamed extraction via finalise() must match original");
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import * as fflate from "fflate";
|
||||
import { delay, promiseWithResolver } from "octagonal-wheels/promises";
|
||||
import { promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import type { XByteArray } from "./types.ts";
|
||||
|
||||
/**
|
||||
|
|
@ -20,7 +20,7 @@ export class Archiver {
|
|||
// )
|
||||
}
|
||||
|
||||
_zipFilePromise = promiseWithResolver<XByteArray>();
|
||||
_zipFilePromise: PromiseWithResolvers<XByteArray> = promiseWithResolvers<XByteArray>();
|
||||
get archivedZipFile(): Promise<XByteArray> {
|
||||
return this._zipFilePromise.promise;
|
||||
}
|
||||
|
|
@ -30,14 +30,14 @@ export class Archiver {
|
|||
}
|
||||
|
||||
constructor() {
|
||||
const zipFile = new fflate.Zip(async (error, dat: XByteArray, final) => this._onProgress(error, dat, final));
|
||||
const zipFile = new fflate.Zip((error, dat: Uint8Array<ArrayBufferLike>, final) => this._onProgress(error, dat, final));
|
||||
this._zipFile = zipFile;
|
||||
}
|
||||
|
||||
_onProgress(err: fflate.FlateError | null, data: XByteArray, final: boolean) {
|
||||
_onProgress(err: fflate.FlateError | null, data: Uint8Array<ArrayBufferLike>, final: boolean) {
|
||||
if (err) return this._onError(err);
|
||||
if (data && data.length > 0) {
|
||||
this._output.push(data);
|
||||
this._output.push(new Uint8Array(data));
|
||||
this._archiveSize += data.length;
|
||||
}
|
||||
// No error
|
||||
|
|
@ -88,7 +88,7 @@ export class Archiver {
|
|||
if (chunkSize > MIN_CHUNK_SIZE) {
|
||||
progress?.(processed, total, false);
|
||||
}
|
||||
await new Promise(res => setTimeout(res, 1));
|
||||
await new Promise(res => window.setTimeout(res, 1));
|
||||
}
|
||||
fflateFile.push(new Uint8Array(), true);
|
||||
progress?.(processed, total, true);
|
||||
|
|
@ -116,15 +116,16 @@ export class Extractor {
|
|||
this._zipFile = unzipper;
|
||||
this._isFileShouldBeExtracted = isFileShouldBeExtracted;
|
||||
this._onExtracted = callback;
|
||||
unzipper.onfile = async (file: fflate.UnzipFile) => {
|
||||
|
||||
const onFile = async (file: fflate.UnzipFile) => {
|
||||
if (await this._isFileShouldBeExtracted(file)) {
|
||||
const data: XByteArray[] = [];
|
||||
file.ondata = async (err, dat: XByteArray, isFinal) => {
|
||||
const onData = async (err: fflate.FlateError | null, dat: Uint8Array, isFinal: boolean) => {
|
||||
if (err) {
|
||||
console.error("Error extracting file", err);
|
||||
return;
|
||||
}
|
||||
if (dat && dat.length > 0) data.push(dat);
|
||||
if (dat && dat.length > 0) data.push(new Uint8Array(dat));
|
||||
|
||||
if (isFinal) {
|
||||
const total = new Blob(data, { type: "application/octet-stream" });
|
||||
|
|
@ -132,9 +133,11 @@ export class Extractor {
|
|||
await this._onExtracted(file.name, result);
|
||||
}
|
||||
};
|
||||
file.ondata = (err, dat, isFinal) => void onData(err, dat, isFinal);
|
||||
file.start();
|
||||
}
|
||||
};
|
||||
unzipper.onfile = (file) => void onFile(file);
|
||||
}
|
||||
|
||||
addZippedContent(data: XByteArray, isFinal = false) {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export class CombinedFragment {
|
|||
* @returns The combined DocumentFragment.
|
||||
*/
|
||||
buildFragment(fragments: (() => DocumentFragment)[]) {
|
||||
const f = document.createDocumentFragment();
|
||||
const f = activeDocument.createDocumentFragment();
|
||||
fragments.forEach(fragment => {
|
||||
f.appendChild(fragment());
|
||||
});
|
||||
|
|
@ -58,7 +58,7 @@ export class CombinedFragment {
|
|||
*/
|
||||
get isVisible() {
|
||||
return Array.from(this._fragment.childNodes).some(e => {
|
||||
return e instanceof HTMLElement && e.isShown();
|
||||
return e.instanceOf(HTMLElement) && e.isShown();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { encrypt, decrypt } from "octagonal-wheels/encryption.js";
|
|||
import DiffZipBackupPlugin from "../main.ts";
|
||||
import { askSelectString } from "./dialog.ts";
|
||||
import { S3Bucket } from "./StorageAccessor/S3Bucket.ts";
|
||||
import { AutoBackupType } from "./types.ts";
|
||||
import { AutoBackupType, type DiffZipBackupSettings } from "./types.ts";
|
||||
|
||||
export class DiffZipSettingTab extends PluginSettingTab {
|
||||
plugin: DiffZipBackupPlugin;
|
||||
|
|
@ -49,6 +49,16 @@ export class DiffZipSettingTab extends PluginSettingTab {
|
|||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Default destructive sync actions")
|
||||
.setDesc("When enabled, Delete defaults to Fetch and Extra (Delete) defaults to Send in selective sync")
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.defaultDestructiveSyncActions).onChange(async (value) => {
|
||||
this.plugin.settings.defaultDestructiveSyncActions = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
containerEl.createEl("h2", { text: "Backup Destination" });
|
||||
const dropDownRemote: Record<string, string> = {
|
||||
"": "Inside the vault",
|
||||
|
|
@ -179,7 +189,8 @@ export class DiffZipSettingTab extends PluginSettingTab {
|
|||
});
|
||||
new Notice("Bucket has been created");
|
||||
} catch (ex) {
|
||||
new Notice(`Bucket creation failed\n-----\n${ex?.message ?? "Unknown error"}`);
|
||||
const message = ex instanceof Error ? ex.message : String(ex);
|
||||
new Notice(`Bucket creation failed\n-----\n${message ?? "Unknown error"}`);
|
||||
console.dir(ex);
|
||||
}
|
||||
})
|
||||
|
|
@ -286,7 +297,7 @@ export class DiffZipSettingTab extends PluginSettingTab {
|
|||
.setWarning()
|
||||
.setButtonText("Reset")
|
||||
.onClick(async () => {
|
||||
this.plugin.resetToC();
|
||||
await this.plugin.resetToC();
|
||||
})
|
||||
);
|
||||
new Setting(containerEl)
|
||||
|
|
@ -327,6 +338,8 @@ export class DiffZipSettingTab extends PluginSettingTab {
|
|||
.addButton((button) => {
|
||||
button.setButtonText("Copy to Clipboard").onClick(async () => {
|
||||
const setting = JSON.stringify(this.plugin.settings);
|
||||
// Compatibility with old versions
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
const encrypted = await encrypt(setting, passphrase, false);
|
||||
const uri = `obsidian://diffzip/settings?data=${encodeURIComponent(encrypted)}`;
|
||||
await navigator.clipboard.writeText(uri);
|
||||
|
|
@ -352,8 +365,10 @@ export class DiffZipSettingTab extends PluginSettingTab {
|
|||
const uri = copiedURI;
|
||||
const data = decodeURIComponent(uri.split("?data=")[1]);
|
||||
try {
|
||||
// Compatibility with old versions
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
const decrypted = await decrypt(data, passphrase, false);
|
||||
const settings = JSON.parse(decrypted);
|
||||
const settings = JSON.parse(decrypted) as DiffZipBackupSettings;
|
||||
if (
|
||||
(await askSelectString(this.app, "Are you sure to overwrite the settings?", [
|
||||
"Yes",
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ export class ProgressFragment {
|
|||
}
|
||||
this.computeNumeric();
|
||||
if (this.isCompleted && this._onComplete) {
|
||||
setTimeout(() => this._onComplete?.(), 10);
|
||||
window.setTimeout(() => this._onComplete?.(), 10);
|
||||
}
|
||||
this.__onProgress();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
)
|
||||
);
|
||||
|
||||
let selectedTimestamp = $state(0);
|
||||
// let selectedTimestamp = $state(0);
|
||||
</script>
|
||||
|
||||
<div class="diffzip-list-row">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
import { type ListOperations } from "./RestoreView.ts";
|
||||
import RestoreFileInfo from "./RestoreFileInfo.svelte";
|
||||
import type { Writable } from "svelte/store";
|
||||
let test = $state("");
|
||||
interface Props {
|
||||
plugin: DiffZipBackupPlugin;
|
||||
toc: FileInfos;
|
||||
|
|
@ -26,7 +25,7 @@
|
|||
function clearList() {
|
||||
fileList.set([]);
|
||||
}
|
||||
function expandFolder(name: string, preventRender = false) {
|
||||
function expandFolder(name: string, _preventRender = false) {
|
||||
const folderPrefix = name.slice(0, -1);
|
||||
const files = allFiles.filter((e) => e.startsWith(folderPrefix));
|
||||
const newFiles = [...new Set([...$fileList, ...files])].filter(
|
||||
|
|
|
|||
|
|
@ -35,6 +35,17 @@ export class DirectVault extends StorageAccessor {
|
|||
return this.app.vault.adapter.readBinary(path);
|
||||
}
|
||||
|
||||
async deleteBinary(path: string): Promise<boolean> {
|
||||
try {
|
||||
if (!(await this.isFileExists(path))) return true;
|
||||
await this.app.vault.adapter.remove(path);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async stat(path: string): Promise<false | Stat> {
|
||||
const stat = await this.app.vault.adapter.stat(path);
|
||||
if (!stat) return false;
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ export class ExternalVaultFilesystem extends StorageAccessor {
|
|||
|
||||
get sep(): string {
|
||||
//@ts-ignore internal API
|
||||
return this.app.vault.adapter.path.sep;
|
||||
return this.app.vault.adapter.path.sep as string;
|
||||
}
|
||||
get fsPromises(): FsAPI {
|
||||
//@ts-ignore internal API
|
||||
return this.app.vault.adapter.fsPromises;
|
||||
return this.app.vault.adapter.fsPromises as FsAPI;
|
||||
}
|
||||
|
||||
async createFolder(absolutePath: string): Promise<void> {
|
||||
|
|
@ -39,10 +39,20 @@ export class ExternalVaultFilesystem extends StorageAccessor {
|
|||
}
|
||||
|
||||
async _readBinary(path: string): Promise<ArrayBuffer | false> {
|
||||
const buffer = await this.fsPromises.readFile(path) as Buffer<ArrayBuffer>;
|
||||
const buffer = await this.fsPromises.readFile(path);
|
||||
return toArrayBuffer(buffer.buffer)
|
||||
}
|
||||
|
||||
async deleteBinary(path: string): Promise<boolean> {
|
||||
try {
|
||||
await this.fsPromises.rm(path, { force: true });
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async checkType(path: string): Promise<FileType> {
|
||||
try {
|
||||
const stat = await this.fsPromises.stat(path);
|
||||
|
|
|
|||
|
|
@ -43,6 +43,21 @@ export class NormalVault extends StorageAccessor {
|
|||
return this.app.vault.adapter.readBinary(path);
|
||||
}
|
||||
|
||||
async deleteBinary(path: string): Promise<boolean> {
|
||||
try {
|
||||
const af = this.app.vault.getAbstractFileByPath(path);
|
||||
if (af == null) return true;
|
||||
if (af instanceof TFile) {
|
||||
await this.app.vault.delete(af, true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async stat(path: string): Promise<false | Stat> {
|
||||
const af = this.app.vault.getAbstractFileByPath(path);
|
||||
if (af == null) return false;
|
||||
|
|
|
|||
|
|
@ -77,6 +77,20 @@ export class S3Bucket extends StorageAccessor {
|
|||
return toArrayBuffer(resultByteArray);
|
||||
}
|
||||
|
||||
async deleteBinary(path: string): Promise<boolean> {
|
||||
const client = await this.getClient();
|
||||
try {
|
||||
await client.deleteObject({
|
||||
Bucket: this.settings.bucket,
|
||||
Key: path,
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
stat(path: string): Promise<false | Stat> {
|
||||
throw new Error("Unsupported operation.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { toArrayBuffer } from "../util.ts";
|
|||
|
||||
|
||||
export abstract class StorageAccessor {
|
||||
type: StorageAccessorType;
|
||||
abstract type: StorageAccessorType;
|
||||
abstract sep: string;
|
||||
public plugin: DiffZipBackupPlugin;
|
||||
get app() {
|
||||
|
|
@ -44,7 +44,7 @@ export abstract class StorageAccessor {
|
|||
const encryptedData = await this._readBinary(path, preventUseCache);
|
||||
if (encryptedData === false) return false;
|
||||
if (!this.isLocal && this.settings.passphraseOfZip) {
|
||||
return toArrayBuffer(await decryptCompatOpenSSL(new Uint8Array(encryptedData), this.settings.passphraseOfZip, 10000) as Uint8Array<ArrayBuffer>);
|
||||
return toArrayBuffer(await decryptCompatOpenSSL(new Uint8Array(encryptedData), this.settings.passphraseOfZip, 10000));
|
||||
}
|
||||
return encryptedData;
|
||||
}
|
||||
|
|
@ -57,7 +57,7 @@ export abstract class StorageAccessor {
|
|||
async writeBinary(path: string, data: ArrayBuffer): Promise<boolean> {
|
||||
let content = data;
|
||||
if (!this.isLocal && this.settings.passphraseOfZip) {
|
||||
content = toArrayBuffer(await encryptCompatOpenSSL(new Uint8Array(data), this.settings.passphraseOfZip, 10000) as Uint8Array<ArrayBuffer>);
|
||||
content = toArrayBuffer(await encryptCompatOpenSSL(new Uint8Array(data), this.settings.passphraseOfZip, 10000));
|
||||
}
|
||||
await this.ensureDirectory(path);
|
||||
return await this._writeBinary(path, content);
|
||||
|
|
@ -71,6 +71,7 @@ export abstract class StorageAccessor {
|
|||
|
||||
abstract _writeBinary(path: string, data: ArrayBuffer): Promise<boolean>;
|
||||
abstract _readBinary(path: string, preventUseCache?: boolean): Promise<ArrayBuffer | false>;
|
||||
abstract deleteBinary(path: string): Promise<boolean>;
|
||||
|
||||
normalizePath(path: string): string {
|
||||
return normalizePath(path);
|
||||
|
|
|
|||
154
src/SyncPlanner.test.ts
Normal file
154
src/SyncPlanner.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import {
|
||||
applySendBatchToToc,
|
||||
getAllowedActions,
|
||||
getDefaultAction,
|
||||
isActionAllowed,
|
||||
planSendBatches,
|
||||
type TocMap,
|
||||
} from "./SyncPlanner.ts";
|
||||
import { pieces } from "./util.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test: (name: string, fn: () => void | Promise<void>) => void;
|
||||
};
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEquals<T>(actual: T, expected: T, message: string) {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(`${message}\nactual=${JSON.stringify(actual)}\nexpected=${JSON.stringify(expected)}`);
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("default actions follow agreed rules", () => {
|
||||
assertEquals(getDefaultAction("Add", { destructiveDefaultsEnabled: false }), "Fetch", "Add must default to Fetch");
|
||||
assertEquals(getDefaultAction("Updated", { destructiveDefaultsEnabled: false }), "Fetch", "Updated must default to Fetch");
|
||||
assertEquals(getDefaultAction("Old", { destructiveDefaultsEnabled: false }), "Send", "Old must default to Send");
|
||||
assertEquals(getDefaultAction("Conflict", { destructiveDefaultsEnabled: false }), "None", "Conflict must default to None");
|
||||
assertEquals(getDefaultAction("Delete", { destructiveDefaultsEnabled: false }), "None", "Delete must default to None when destructive defaults are off");
|
||||
assertEquals(getDefaultAction("Delete", { destructiveDefaultsEnabled: true }), "Fetch", "Delete must default to Fetch when destructive defaults are on");
|
||||
assertEquals(getDefaultAction("Extra (Delete)", { destructiveDefaultsEnabled: false }), "None", "Extra must default to None when destructive defaults are off");
|
||||
assertEquals(getDefaultAction("Extra (Delete)", { destructiveDefaultsEnabled: true }), "Send", "Extra must default to Send when destructive defaults are on");
|
||||
});
|
||||
|
||||
Deno.test("allowed action matrix is constrained", () => {
|
||||
assertEquals(getAllowedActions("Add"), ["None", "Fetch"], "Add actions must be limited");
|
||||
assertEquals(getAllowedActions("Extra (Delete)"), ["None", "Send"], "Extra actions must be limited");
|
||||
assertEquals(getAllowedActions("Same"), ["None"], "Same actions must be limited");
|
||||
assert(isActionAllowed("Updated", "Send"), "Updated should allow Send");
|
||||
assert(!isActionAllowed("Add", "Send"), "Add should not allow Send");
|
||||
assert(!isActionAllowed("Extra (Delete)", "Fetch"), "Extra should not allow Fetch");
|
||||
});
|
||||
|
||||
Deno.test("send batch planner respects both file-count and total-size limits", () => {
|
||||
const { batches, oversizedFiles } = planSendBatches(
|
||||
[
|
||||
{ filename: "a.md", size: 4 },
|
||||
{ filename: "b.md", size: 5 },
|
||||
{ filename: "c.md", size: 3 },
|
||||
{ filename: "huge.bin", size: 20 },
|
||||
{ filename: "d.md", size: 4 },
|
||||
],
|
||||
2,
|
||||
10,
|
||||
);
|
||||
|
||||
assertEquals(oversizedFiles, ["huge.bin"], "Oversized file should be reported");
|
||||
assertEquals(
|
||||
batches.map((b) => b.files.map((f) => f.filename)),
|
||||
[["a.md", "b.md"], ["c.md"], ["huge.bin"], ["d.md"]],
|
||||
"Batches should split by both limits, oversized in solo batch",
|
||||
);
|
||||
assertEquals(
|
||||
batches.map((b) => b.totalSize),
|
||||
[9, 3, 20, 4],
|
||||
"Batch total sizes should be tracked",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("TOC updates can be applied sequentially per committed batch", () => {
|
||||
const initial: TocMap = {
|
||||
"note.md": {
|
||||
filename: "note.md",
|
||||
digest: "old",
|
||||
mtime: 1,
|
||||
history: [
|
||||
{
|
||||
zipName: "old.zip",
|
||||
modified: new Date(1).toISOString(),
|
||||
digest: "old",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const afterFirst = applySendBatchToToc(
|
||||
initial,
|
||||
[{ kind: "file", filename: "note.md", digest: "new-1", mtime: 1000 }],
|
||||
"sync-1.zip",
|
||||
2000,
|
||||
);
|
||||
|
||||
const afterSecond = applySendBatchToToc(
|
||||
afterFirst,
|
||||
[
|
||||
{ kind: "file", filename: "new.md", digest: "new-2", mtime: 3000 },
|
||||
{ kind: "missing", filename: "obsolete.md", modifiedTime: 4000 },
|
||||
],
|
||||
"sync-2.zip",
|
||||
5000,
|
||||
);
|
||||
|
||||
assertEquals(afterFirst["note.md"].history.length, 2, "First batch should append one history entry");
|
||||
assertEquals(afterSecond["note.md"].history.length, 2, "Second batch should keep first batch history");
|
||||
const latestHistory = afterSecond["new.md"].history[afterSecond["new.md"].history.length - 1];
|
||||
assertEquals(latestHistory?.zipName, "sync-2.zip", "Second batch should add new file with matching zip name");
|
||||
assert(afterSecond["obsolete.md"].missing === true, "Missing update should mark file as missing");
|
||||
});
|
||||
|
||||
Deno.test("ZIP files are split into chunks when exceeding maxSize", () => {
|
||||
const chunkSize = 10; // bytes
|
||||
const data = new Uint8Array(35); // Create 35 bytes
|
||||
data.fill(42); // Fill with arbitrary data
|
||||
|
||||
const chunks = Array.from(pieces(data, chunkSize));
|
||||
|
||||
assertEquals(chunks.length, 4, "35 bytes should be split into 4 chunks (10+10+10+5)");
|
||||
assertEquals(chunks[0].length, 10, "First chunk should be 10 bytes");
|
||||
assertEquals(chunks[1].length, 10, "Second chunk should be 10 bytes");
|
||||
assertEquals(chunks[2].length, 10, "Third chunk should be 10 bytes");
|
||||
assertEquals(chunks[3].length, 5, "Fourth chunk should be 5 bytes (remainder)");
|
||||
|
||||
// Verify reassembled data matches original
|
||||
const reassembled = new Uint8Array(chunks.reduce((a, b) => a + b.length, 0));
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
reassembled.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
assertEquals(reassembled, data, "Reassembled chunks should match original data");
|
||||
});
|
||||
|
||||
Deno.test("ZIP chunk splitting: multiple large chunks with remainder", () => {
|
||||
// Simulate a large ZIP (500 KB) with 100 KB chunks
|
||||
const largeZip = new Uint8Array(500000);
|
||||
const chunkSize = 100000;
|
||||
const chunks = Array.from(pieces(largeZip, chunkSize));
|
||||
|
||||
assertEquals(chunks.length, 5, "500KB split into 100KB chunks should create 5 chunks");
|
||||
assertEquals(chunks[0].length, 100000, "Chunks 0-3 should be 100KB");
|
||||
assertEquals(chunks[4].length, 100000, "Chunk 4 should be 100KB");
|
||||
|
||||
// Track which would be the written filenames in writeSendZip
|
||||
const fileNames = chunks.map((_, i) =>
|
||||
i === 0 ? "sync-test.zip" : `sync-test.zip.${String(i).padStart(3, "0")}`,
|
||||
);
|
||||
assertEquals(fileNames.length, 5, "Should create 5 file names for 5 chunks");
|
||||
assertEquals(fileNames[0], "sync-test.zip", "First file is base name");
|
||||
assertEquals(fileNames[1], "sync-test.zip.001", "Second file has .001");
|
||||
assertEquals(fileNames[4], "sync-test.zip.004", "Fifth file has .004");
|
||||
});
|
||||
186
src/SyncPlanner.ts
Normal file
186
src/SyncPlanner.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
export type SyncOperation =
|
||||
| "Add"
|
||||
| "Updated"
|
||||
| "Old"
|
||||
| "Conflict"
|
||||
| "Delete"
|
||||
| "Extra (Delete)"
|
||||
| "Same";
|
||||
|
||||
export type SyncAction = "None" | "Fetch" | "Send";
|
||||
|
||||
export type PlannerOptions = {
|
||||
destructiveDefaultsEnabled: boolean;
|
||||
};
|
||||
|
||||
export function getAllowedActions(operation: SyncOperation): SyncAction[] {
|
||||
if (operation === "Same") return ["None"];
|
||||
if (operation === "Add") return ["None", "Fetch"];
|
||||
if (operation === "Extra (Delete)") return ["None", "Send"];
|
||||
return ["None", "Fetch", "Send"];
|
||||
}
|
||||
|
||||
export function isActionAllowed(operation: SyncOperation, action: SyncAction): boolean {
|
||||
return getAllowedActions(operation).includes(action);
|
||||
}
|
||||
|
||||
export function getDefaultAction(operation: SyncOperation, options: PlannerOptions): SyncAction {
|
||||
switch (operation) {
|
||||
case "Add":
|
||||
case "Updated":
|
||||
return "Fetch";
|
||||
case "Old":
|
||||
return "Send";
|
||||
case "Delete":
|
||||
return options.destructiveDefaultsEnabled ? "Fetch" : "None";
|
||||
case "Extra (Delete)":
|
||||
return options.destructiveDefaultsEnabled ? "Send" : "None";
|
||||
case "Conflict":
|
||||
case "Same":
|
||||
default:
|
||||
return "None";
|
||||
}
|
||||
}
|
||||
|
||||
export type SendFileCandidate = {
|
||||
filename: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export type SendBatch = {
|
||||
files: SendFileCandidate[];
|
||||
totalSize: number;
|
||||
};
|
||||
|
||||
export function planSendBatches(
|
||||
candidates: SendFileCandidate[],
|
||||
maxFilesInZip: number,
|
||||
maxTotalSizeInZip: number,
|
||||
): { batches: SendBatch[]; oversizedFiles: string[] } {
|
||||
const batches: SendBatch[] = [];
|
||||
const oversizedFiles: string[] = [];
|
||||
|
||||
let current: SendBatch = { files: [], totalSize: 0 };
|
||||
|
||||
const flush = () => {
|
||||
if (current.files.length > 0) {
|
||||
batches.push(current);
|
||||
current = { files: [], totalSize: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
for (const file of candidates) {
|
||||
if (maxTotalSizeInZip > 0 && file.size > maxTotalSizeInZip) {
|
||||
// File is too large for a single ZIP, but include it anyway
|
||||
// Flush current batch first
|
||||
flush();
|
||||
// Create a solo batch for this oversized file
|
||||
batches.push({ files: [file], totalSize: file.size });
|
||||
oversizedFiles.push(file.filename);
|
||||
continue;
|
||||
}
|
||||
|
||||
const exceedsFileCount = maxFilesInZip > 0 && current.files.length >= maxFilesInZip;
|
||||
const exceedsTotalSize =
|
||||
maxTotalSizeInZip > 0 &&
|
||||
current.files.length > 0 &&
|
||||
current.totalSize + file.size > maxTotalSizeInZip;
|
||||
|
||||
if (exceedsFileCount || exceedsTotalSize) {
|
||||
flush();
|
||||
}
|
||||
|
||||
current.files.push(file);
|
||||
current.totalSize += file.size;
|
||||
}
|
||||
|
||||
flush();
|
||||
|
||||
return { batches, oversizedFiles };
|
||||
}
|
||||
|
||||
export type TocHistory = {
|
||||
zipName: string;
|
||||
modified: string;
|
||||
missing?: boolean;
|
||||
processed?: number;
|
||||
digest: string;
|
||||
};
|
||||
|
||||
export type TocEntry = {
|
||||
filename: string;
|
||||
digest: string;
|
||||
history: TocHistory[];
|
||||
mtime: number;
|
||||
processed?: number;
|
||||
missing?: boolean;
|
||||
};
|
||||
|
||||
export type TocMap = Record<string, TocEntry>;
|
||||
|
||||
export type TocUpdate =
|
||||
| {
|
||||
kind: "file";
|
||||
filename: string;
|
||||
digest: string;
|
||||
mtime: number;
|
||||
}
|
||||
| {
|
||||
kind: "missing";
|
||||
filename: string;
|
||||
modifiedTime: number;
|
||||
};
|
||||
|
||||
export function applySendBatchToToc(
|
||||
toc: TocMap,
|
||||
updates: TocUpdate[],
|
||||
zipName: string,
|
||||
processedAt: number,
|
||||
): TocMap {
|
||||
const next: TocMap = { ...toc };
|
||||
|
||||
for (const update of updates) {
|
||||
const existing = next[update.filename];
|
||||
const existingHistory = existing?.history ?? [];
|
||||
|
||||
if (update.kind === "file") {
|
||||
next[update.filename] = {
|
||||
filename: update.filename,
|
||||
digest: update.digest,
|
||||
mtime: update.mtime,
|
||||
processed: processedAt,
|
||||
missing: false,
|
||||
history: [
|
||||
...existingHistory,
|
||||
{
|
||||
zipName,
|
||||
modified: new Date(update.mtime).toISOString(),
|
||||
processed: processedAt,
|
||||
digest: update.digest,
|
||||
},
|
||||
],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
next[update.filename] = {
|
||||
filename: update.filename,
|
||||
digest: "",
|
||||
mtime: update.modifiedTime,
|
||||
processed: processedAt,
|
||||
missing: true,
|
||||
history: [
|
||||
...existingHistory,
|
||||
{
|
||||
zipName,
|
||||
modified: new Date(update.modifiedTime).toISOString(),
|
||||
missing: true,
|
||||
processed: processedAt,
|
||||
digest: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
<script lang="ts">
|
||||
import type { SyncItem, SyncOperation } from "./SyncRemoteDialog.ts";
|
||||
import type { SyncAction, SyncItem, SyncOperation } from "./SyncRemoteDialog.ts";
|
||||
|
||||
interface Props {
|
||||
initialItems: SyncItem[];
|
||||
onApply: (items: SyncItem[]) => Promise<void>;
|
||||
onApply: (_items: SyncItem[]) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -19,50 +19,66 @@
|
|||
|
||||
const opClass: Record<SyncOperation, string> = {
|
||||
Add: "op-add",
|
||||
Update: "op-update",
|
||||
Revert: "op-revert",
|
||||
Updated: "op-updated",
|
||||
Old: "op-old",
|
||||
Conflict: "op-conflict",
|
||||
Delete: "op-delete",
|
||||
Extra: "op-extra",
|
||||
"Extra (Delete)": "op-extra",
|
||||
Same: "op-same",
|
||||
};
|
||||
|
||||
function toggle(filename: string) {
|
||||
function setAction(filename: string, action: SyncAction) {
|
||||
items = items.map((i) =>
|
||||
i.filename === filename ? { ...i, checked: !i.checked } : i,
|
||||
i.filename === filename ? { ...i, action } : i,
|
||||
);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
items = items.map((i) => ({ ...i, checked: false }));
|
||||
items = items.map((i) => ({ ...i, action: "None" }));
|
||||
}
|
||||
|
||||
function synchroniseEdits() {
|
||||
function fetchAll() {
|
||||
items = items.map((i) => ({
|
||||
...i,
|
||||
checked: ["Add", "Update", "Delete"].includes(i.operation),
|
||||
action: i.allowedActions.includes("Fetch") ? "Fetch" : "None",
|
||||
}));
|
||||
}
|
||||
|
||||
function timetravelAll() {
|
||||
function sendAll() {
|
||||
items = items.map((i) => ({
|
||||
...i,
|
||||
checked: i.operation !== "Same" && i.operation !== "Conflict",
|
||||
action: i.allowedActions.includes("Send") ? "Send" : "None",
|
||||
}));
|
||||
}
|
||||
|
||||
function clearDeletion() {
|
||||
items = items.map((i) =>
|
||||
["Delete", "Extra"].includes(i.operation)
|
||||
? { ...i, checked: false }
|
||||
: i,
|
||||
);
|
||||
function sync() {
|
||||
items = items.map((i) => ({
|
||||
...i,
|
||||
action: i.defaultAction,
|
||||
}));
|
||||
}
|
||||
|
||||
function syncWithDeletion() {
|
||||
items = items.map((i) => {
|
||||
// For Delete/Extra operations, force their default destructive actions
|
||||
if (i.operation === "Delete" || i.operation === "Extra (Delete)") {
|
||||
const action = i.operation === "Delete" ? "Fetch" : "Send";
|
||||
return {
|
||||
...i,
|
||||
action: i.allowedActions.includes(action) ? action : "None",
|
||||
};
|
||||
}
|
||||
// For other operations, use defaultAction
|
||||
return {
|
||||
...i,
|
||||
action: i.defaultAction,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function handleApply() {
|
||||
applying = true;
|
||||
const checked = items.filter((i) => i.checked);
|
||||
await onApply(checked);
|
||||
await onApply(items);
|
||||
applying = false;
|
||||
}
|
||||
|
||||
|
|
@ -81,7 +97,7 @@
|
|||
<table class="diffzip-sync-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check">✓</th>
|
||||
<th class="col-check">Action</th>
|
||||
<th class="col-path">File Path</th>
|
||||
<th class="col-op">Operation</th>
|
||||
<th class="col-zip">ZIP File</th>
|
||||
|
|
@ -92,13 +108,22 @@
|
|||
{#each visibleItems as item (item.filename)}
|
||||
<tr class:op-same-row={item.operation === "Same"}>
|
||||
<td class="col-check">
|
||||
{#if item.operation !== "Same"}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onchange={() => toggle(item.filename)}
|
||||
/>
|
||||
{/if}
|
||||
<fieldset class="action-radio-group">
|
||||
{#each item.allowedActions as action}
|
||||
<label class="action-radio">
|
||||
<input
|
||||
type="radio"
|
||||
name={`action-${item.filename}`}
|
||||
value={action}
|
||||
checked={item.action === action}
|
||||
onchange={() => setAction(item.filename, action)}
|
||||
/>
|
||||
<span class="action-emoji" title={action}>
|
||||
{action === "None" ? "⊘" : action === "Fetch" ? "⬇️" : "⬆️"}
|
||||
</span>
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
</td>
|
||||
<td class="col-path">{item.filename}</td>
|
||||
<td class="col-op">
|
||||
|
|
@ -123,9 +148,10 @@
|
|||
|
||||
<div class="diffzip-sync-buttons">
|
||||
<button onclick={clear}>Clear</button>
|
||||
<button onclick={synchroniseEdits}>Synchronise Edits</button>
|
||||
<button onclick={timetravelAll}>Timetravel</button>
|
||||
<button onclick={clearDeletion}>Clear Deletion</button>
|
||||
<button onclick={sync}>Sync</button>
|
||||
<button onclick={syncWithDeletion}>Sync W/ deletion</button>
|
||||
<button onclick={fetchAll}>Fetch All</button>
|
||||
<button onclick={sendAll}>Send All</button>
|
||||
</div>
|
||||
|
||||
<div class="diffzip-sync-footer">
|
||||
|
|
|
|||
|
|
@ -1,34 +1,40 @@
|
|||
import { Modal, Notice, TFile, type App } from "obsidian";
|
||||
import { Modal, Notice, TFile, stringifyYaml, type App } from "obsidian";
|
||||
import { mount, unmount } from "svelte";
|
||||
import { computeDigest } from "./util.ts";
|
||||
import { Archiver } from "./Archive.ts";
|
||||
import { computeDigest, pieces, toArrayBuffer } from "./util.ts";
|
||||
import { ProgressFragment } from "./ProgressFragment.ts";
|
||||
import { confirmWithMessage } from "./dialog.ts";
|
||||
import type { FileInfos } from "./types.ts";
|
||||
import { InfoFile, type FileInfos } from "./types.ts";
|
||||
import type DiffZipBackupPlugin from "../main.ts";
|
||||
import SyncRemoteComponent from "./SyncRemote.svelte";
|
||||
import {
|
||||
applySendBatchToToc,
|
||||
getAllowedActions,
|
||||
getDefaultAction,
|
||||
isActionAllowed,
|
||||
planSendBatches,
|
||||
type SyncAction,
|
||||
type SyncOperation,
|
||||
type TocUpdate,
|
||||
} from "./SyncPlanner.ts";
|
||||
|
||||
export type SyncOperation =
|
||||
| "Add"
|
||||
| "Update"
|
||||
| "Revert"
|
||||
| "Conflict"
|
||||
| "Delete"
|
||||
| "Extra (Delete)"
|
||||
| "Same";
|
||||
export type { SyncAction, SyncOperation } from "./SyncPlanner.ts";
|
||||
|
||||
export type SyncItem = {
|
||||
filename: string;
|
||||
operation: SyncOperation;
|
||||
zipName: string;
|
||||
modified: string;
|
||||
checked: boolean;
|
||||
action: SyncAction;
|
||||
allowedActions: SyncAction[];
|
||||
defaultAction: SyncAction;
|
||||
};
|
||||
|
||||
const OP_ORDER: Record<SyncOperation, number> = {
|
||||
Conflict: 0,
|
||||
Add: 1,
|
||||
Update: 2,
|
||||
Revert: 3,
|
||||
Updated: 2,
|
||||
Old: 3,
|
||||
Delete: 4,
|
||||
"Extra (Delete)": 5,
|
||||
Same: 6,
|
||||
|
|
@ -46,7 +52,7 @@ export class SyncRemoteDialog extends Modal {
|
|||
|
||||
async onOpen() {
|
||||
const { contentEl } = this;
|
||||
this.titleEl.setText("Selective Apply Remote");
|
||||
this.titleEl.setText("Selective Sync Remote");
|
||||
contentEl.empty();
|
||||
|
||||
const progress = new ProgressFragment({
|
||||
|
|
@ -94,10 +100,40 @@ export class SyncRemoteDialog extends Modal {
|
|||
const items: SyncItem[] = [];
|
||||
const pluginDir = this.plugin.manifest.dir;
|
||||
|
||||
// Build ignore patterns when hidden folders are not included
|
||||
const ignorePatterns: string[] = [];
|
||||
if (!this.plugin.settings.includeHiddenFolder) {
|
||||
ignorePatterns.push(
|
||||
"node_modules",
|
||||
".git",
|
||||
this.plugin.app.vault.configDir + "/trash",
|
||||
this.plugin.app.vault.configDir + "/workspace.json",
|
||||
this.plugin.app.vault.configDir + "/workspace-mobile.json",
|
||||
);
|
||||
}
|
||||
|
||||
const shouldIgnoreFile = (filename: string): boolean => {
|
||||
if (ignorePatterns.length === 0) return false;
|
||||
// Check if file matches any ignore pattern
|
||||
for (const pattern of ignorePatterns) {
|
||||
if (filename.endsWith(pattern) || filename.startsWith(pattern + "/")) {
|
||||
return true;
|
||||
}
|
||||
// Check for hidden files/folders in path
|
||||
if (filename.split("/").some((part) => part.startsWith("."))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
for (const [filename, fileInfo] of Object.entries(remoteToc)) {
|
||||
// Skip plugin own files (same as restoreVault)
|
||||
if (pluginDir && filename.startsWith(pluginDir)) continue;
|
||||
|
||||
// Skip hidden files/folders when includeHiddenFolder is false
|
||||
if (shouldIgnoreFile(filename)) continue;
|
||||
|
||||
const history = [...fileInfo.history].sort(
|
||||
(a, b) =>
|
||||
new Date(b.modified).getTime() -
|
||||
|
|
@ -107,74 +143,68 @@ export class SyncRemoteDialog extends Modal {
|
|||
const latest = history[0];
|
||||
const isRemoteMissing = fileInfo.missing === true;
|
||||
const localInfo = localFileMap.get(filename);
|
||||
let operation: SyncOperation | undefined;
|
||||
|
||||
if (isRemoteMissing) {
|
||||
if (localInfo) {
|
||||
items.push({
|
||||
filename,
|
||||
operation: "Delete",
|
||||
zipName: latest.zipName,
|
||||
modified: latest.modified,
|
||||
checked: true,
|
||||
});
|
||||
operation = "Delete";
|
||||
}
|
||||
} else if (!localInfo) {
|
||||
items.push({
|
||||
filename,
|
||||
operation: "Add",
|
||||
zipName: latest.zipName,
|
||||
modified: latest.modified,
|
||||
checked: true,
|
||||
});
|
||||
operation = "Add";
|
||||
} else if (latest.digest === localInfo.digest) {
|
||||
items.push({
|
||||
filename,
|
||||
operation: "Same",
|
||||
zipName: "",
|
||||
modified: latest.modified,
|
||||
checked: false,
|
||||
});
|
||||
operation = "Same";
|
||||
} else {
|
||||
const remoteMtime = new Date(latest.modified).getTime();
|
||||
const localMtime = localInfo.mtime;
|
||||
if (remoteMtime > localMtime) {
|
||||
items.push({
|
||||
filename,
|
||||
operation: "Update",
|
||||
zipName: latest.zipName,
|
||||
modified: latest.modified,
|
||||
checked: true,
|
||||
});
|
||||
operation = "Updated";
|
||||
} else if (remoteMtime < localMtime) {
|
||||
items.push({
|
||||
filename,
|
||||
operation: "Revert",
|
||||
zipName: latest.zipName,
|
||||
modified: latest.modified,
|
||||
checked: true,
|
||||
});
|
||||
operation = "Old";
|
||||
} else {
|
||||
// Same mtime, different digest → Conflict
|
||||
items.push({
|
||||
filename,
|
||||
operation: "Conflict",
|
||||
zipName: latest.zipName,
|
||||
modified: latest.modified,
|
||||
checked: false,
|
||||
});
|
||||
operation = "Conflict";
|
||||
}
|
||||
}
|
||||
|
||||
if (operation) {
|
||||
const allowedActions = getAllowedActions(operation);
|
||||
const defaultAction = getDefaultAction(operation, {
|
||||
destructiveDefaultsEnabled: this.plugin.settings.defaultDestructiveSyncActions,
|
||||
});
|
||||
const action = isActionAllowed(operation, defaultAction)
|
||||
? defaultAction
|
||||
: "None";
|
||||
items.push({
|
||||
filename,
|
||||
operation,
|
||||
zipName: latest.zipName,
|
||||
modified: latest.modified,
|
||||
action,
|
||||
allowedActions,
|
||||
defaultAction,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Extra: local files not tracked by remote TOC at all
|
||||
for (const filename of localFileMap.keys()) {
|
||||
if (!(filename in remoteToc)) {
|
||||
const operation: SyncOperation = "Extra (Delete)";
|
||||
const allowedActions = getAllowedActions(operation);
|
||||
const defaultAction = getDefaultAction(operation, {
|
||||
destructiveDefaultsEnabled: this.plugin.settings.defaultDestructiveSyncActions,
|
||||
});
|
||||
const action = isActionAllowed(operation, defaultAction)
|
||||
? defaultAction
|
||||
: "None";
|
||||
items.push({
|
||||
filename,
|
||||
operation: "Extra (Delete)",
|
||||
operation,
|
||||
zipName: "",
|
||||
modified: "",
|
||||
checked: false,
|
||||
action,
|
||||
allowedActions,
|
||||
defaultAction,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -190,8 +220,8 @@ export class SyncRemoteDialog extends Modal {
|
|||
target: contentEl,
|
||||
props: {
|
||||
initialItems: items,
|
||||
onApply: async (checkedItems: SyncItem[]) => {
|
||||
await this.applySync(checkedItems);
|
||||
onApply: async (updatedItems: SyncItem[]) => {
|
||||
await this.applySync(updatedItems);
|
||||
},
|
||||
onCancel: () => this.close(),
|
||||
},
|
||||
|
|
@ -204,15 +234,26 @@ export class SyncRemoteDialog extends Modal {
|
|||
}
|
||||
}
|
||||
|
||||
async applySync(checkedItems: SyncItem[]) {
|
||||
const destructiveOps = checkedItems.filter((i) =>
|
||||
["Delete", "Revert", "Extra", "Conflict"].includes(i.operation),
|
||||
async applySync(items: SyncItem[]) {
|
||||
const fetchItems = items.filter((i) => i.action === "Fetch");
|
||||
const sendItems = items.filter((i) => i.action === "Send");
|
||||
|
||||
if (fetchItems.length === 0 && sendItems.length === 0) {
|
||||
this.close();
|
||||
new Notice("No sync action selected.");
|
||||
return;
|
||||
}
|
||||
|
||||
const destructiveOps = items.filter(
|
||||
(i) =>
|
||||
i.action !== "None" &&
|
||||
["Delete", "Extra (Delete)"].includes(i.operation),
|
||||
);
|
||||
|
||||
if (destructiveOps.length > 0) {
|
||||
const APPLY = "Apply";
|
||||
const CANCEL = "Cancel";
|
||||
const msg = `**${destructiveOps.length}** destructive operation(s) are selected (Delete / Revert / Extra / Conflict).\n\nAre you sure you want to proceed?`;
|
||||
const msg = `**${destructiveOps.length}** destructive sync operation(s) are selected (Delete / Extra (Delete)).\n\nAre you sure you want to proceed?`;
|
||||
const result = await confirmWithMessage(
|
||||
this.plugin,
|
||||
"Confirm Apply",
|
||||
|
|
@ -223,11 +264,48 @@ export class SyncRemoteDialog extends Modal {
|
|||
if (result !== APPLY) return;
|
||||
}
|
||||
|
||||
// Group files by ZIP for efficient extraction
|
||||
this.close();
|
||||
|
||||
let fetched = 0;
|
||||
let sent = 0;
|
||||
|
||||
if (fetchItems.length > 0) {
|
||||
const fetchResult = await this.applyFetch(fetchItems);
|
||||
fetched = fetchResult.done;
|
||||
if (fetchResult.failed.length > 0) {
|
||||
const msg = `**${fetched - fetchResult.failed.length}** file(s) fetched successfully.\n\n**${fetchResult.failed.length}** file(s) failed:\n${fetchResult.failed.map((f) => `- ${f}`).join("\n")}\n\nSend phase was skipped because fetch failed.`;
|
||||
await confirmWithMessage(
|
||||
this.plugin,
|
||||
"Sync stopped by fetch errors",
|
||||
msg,
|
||||
["Close"],
|
||||
"Close",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (sendItems.length > 0) {
|
||||
try {
|
||||
sent = await this.applySend(sendItems);
|
||||
} catch (e) {
|
||||
new Notice(
|
||||
`Send phase failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
new Notice(
|
||||
`Sync completed. Fetch: ${fetched} file(s), Send: ${sent} file(s).`,
|
||||
);
|
||||
}
|
||||
|
||||
async applyFetch(fetchItems: SyncItem[]) {
|
||||
const zipFileMap = new Map<string, string[]>();
|
||||
const deleteFiles: string[] = [];
|
||||
for (const item of checkedItems) {
|
||||
if (item.operation === "Delete" || item.operation === "Extra (Delete)") {
|
||||
for (const item of fetchItems) {
|
||||
if (item.operation === "Delete") {
|
||||
deleteFiles.push(item.filename);
|
||||
} else {
|
||||
const arr = zipFileMap.get(item.zipName) ?? [];
|
||||
|
|
@ -236,8 +314,6 @@ export class SyncRemoteDialog extends Modal {
|
|||
}
|
||||
}
|
||||
|
||||
this.close();
|
||||
|
||||
const totalOps =
|
||||
[...zipFileMap.values()].reduce((a, b) => a + b.length, 0) +
|
||||
deleteFiles.length;
|
||||
|
|
@ -287,23 +363,214 @@ export class SyncRemoteDialog extends Modal {
|
|||
|
||||
if (failed.length > 0) {
|
||||
const msg = `**${done - failed.length}** file(s) mirrored successfully.\n\n**${failed.length}** file(s) failed:\n${failed.map((f) => `- ${f}`).join("\n")}`;
|
||||
await confirmWithMessage(
|
||||
this.plugin,
|
||||
"Mirror completed with errors",
|
||||
msg,
|
||||
["Close"],
|
||||
"Close",
|
||||
);
|
||||
} else {
|
||||
new Notice(
|
||||
`${done} file${done !== 1 ? "s" : ""} mirrored successfully.`,
|
||||
this.plugin.logWrite(msg);
|
||||
}
|
||||
|
||||
return { done, failed };
|
||||
}
|
||||
|
||||
makeSyncZipName(batchIndex: number): string {
|
||||
const today = new Date();
|
||||
const secondsInDay =
|
||||
~~(today.getTime() / 1000 - today.getTimezoneOffset() * 60) % 86400;
|
||||
return `${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate()}-${secondsInDay}-sync-${batchIndex + 1}.zip`;
|
||||
}
|
||||
|
||||
async rollbackWrittenZipFiles(writtenFiles: string[]) {
|
||||
const failed: string[] = [];
|
||||
for (const path of writtenFiles) {
|
||||
const ok = await this.plugin.backups.deleteBinary(path);
|
||||
if (!ok) failed.push(path);
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
this.plugin.logWrite(
|
||||
`Failed to rollback ${failed.length} ZIP file(s):\n${failed.join("\n")}`,
|
||||
);
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
|
||||
async writeSendZip(
|
||||
zipName: string,
|
||||
files: { filename: string; content: Uint8Array<ArrayBuffer>; mtime: number }[],
|
||||
toc: FileInfos,
|
||||
) {
|
||||
const zip = new Archiver();
|
||||
for (const file of files) {
|
||||
zip.addFile(file.content, file.filename, { mtime: file.mtime });
|
||||
}
|
||||
|
||||
const tocTimeStamp = new Date().getTime();
|
||||
zip.addTextFile(`\`\`\`\n${stringifyYaml(toc)}\n\`\`\`\n`, InfoFile, {
|
||||
mtime: tocTimeStamp,
|
||||
});
|
||||
|
||||
const buf = await zip.finalize();
|
||||
const step =
|
||||
this.plugin.settings.maxSize / 1 == 0
|
||||
? buf.byteLength + 1
|
||||
: (this.plugin.settings.maxSize / 1) * 1024 * 1024;
|
||||
let pieceCount = 0;
|
||||
if (buf.byteLength > step) pieceCount = 1;
|
||||
|
||||
const writtenFiles: string[] = [];
|
||||
const chunks = pieces(buf, step);
|
||||
for (const chunk of chunks) {
|
||||
const outZipFile = this.plugin.backups.normalizePath(
|
||||
`${this.plugin.backupFolder}${this.plugin.sep}${zipName}${pieceCount == 0 ? "" : "." + `00${pieceCount}`.slice(-3)}`,
|
||||
);
|
||||
pieceCount++;
|
||||
const ok = await this.plugin.backups.writeBinary(
|
||||
outZipFile,
|
||||
toArrayBuffer(chunk),
|
||||
);
|
||||
if (!ok) {
|
||||
await this.rollbackWrittenZipFiles(writtenFiles);
|
||||
throw new Error(`Creating ${outZipFile} failed`);
|
||||
}
|
||||
writtenFiles.push(outZipFile);
|
||||
}
|
||||
|
||||
return writtenFiles;
|
||||
}
|
||||
|
||||
async applySend(sendItems: SyncItem[]) {
|
||||
const preparedFiles = [] as {
|
||||
filename: string;
|
||||
content: Uint8Array<ArrayBuffer>;
|
||||
digest: string;
|
||||
mtime: number;
|
||||
size: number;
|
||||
}[];
|
||||
const preparedMissing = [] as { filename: string; modifiedTime: number }[];
|
||||
|
||||
for (const item of sendItems) {
|
||||
const normalized = this.plugin.vaultAccess.normalizePath(item.filename);
|
||||
const stat = await this.plugin.vaultAccess.stat(normalized);
|
||||
if (!stat) {
|
||||
preparedMissing.push({
|
||||
filename: item.filename,
|
||||
modifiedTime: Date.now(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = await this.plugin.vaultAccess.readBinary(normalized);
|
||||
if (content === false) {
|
||||
throw new Error(`Could not read local file: ${item.filename}`);
|
||||
}
|
||||
const bytes = new Uint8Array(content);
|
||||
const digest = await computeDigest(bytes);
|
||||
preparedFiles.push({
|
||||
filename: item.filename,
|
||||
content: bytes,
|
||||
digest,
|
||||
mtime: stat.mtime,
|
||||
size: bytes.byteLength,
|
||||
});
|
||||
}
|
||||
|
||||
const maxFilesInZip = this.plugin.settings.maxFilesInZip;
|
||||
const maxTotalSizeInZip =
|
||||
this.plugin.settings.maxTotalSizeInZip > 0
|
||||
? this.plugin.settings.maxTotalSizeInZip * 1024 * 1024
|
||||
: 0;
|
||||
|
||||
const { batches, oversizedFiles } = planSendBatches(
|
||||
preparedFiles.map((f) => ({ filename: f.filename, size: f.size })),
|
||||
maxFilesInZip,
|
||||
maxTotalSizeInZip,
|
||||
);
|
||||
|
||||
if (oversizedFiles.length > 0) {
|
||||
this.plugin.logWrite(
|
||||
`⚠️ These files exceed max total source size in a single ZIP and will be placed in individual ZIPs:\n${oversizedFiles.join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const preparedFileByPath = new Map(
|
||||
preparedFiles.map((f) => [f.filename, f] as const),
|
||||
);
|
||||
|
||||
const effectiveBatches =
|
||||
batches.length > 0 ? batches : [{ files: [], totalSize: 0 }];
|
||||
|
||||
let toc = await this.plugin.loadTOC();
|
||||
let sentCount = 0;
|
||||
|
||||
for (let index = 0; index < effectiveBatches.length; index++) {
|
||||
const batch = effectiveBatches[index];
|
||||
const zipName = this.makeSyncZipName(index);
|
||||
const processedAt = Date.now();
|
||||
const updates = [] as TocUpdate[];
|
||||
const zippedFiles = [] as {
|
||||
filename: string;
|
||||
content: Uint8Array<ArrayBuffer>;
|
||||
mtime: number;
|
||||
}[];
|
||||
|
||||
for (const planned of batch.files) {
|
||||
const prepared = preparedFileByPath.get(planned.filename);
|
||||
if (!prepared) {
|
||||
throw new Error(`Planned file is missing in preparation: ${planned.filename}`);
|
||||
}
|
||||
updates.push({
|
||||
kind: "file",
|
||||
filename: prepared.filename,
|
||||
digest: prepared.digest,
|
||||
mtime: prepared.mtime,
|
||||
});
|
||||
zippedFiles.push({
|
||||
filename: prepared.filename,
|
||||
content: prepared.content,
|
||||
mtime: prepared.mtime,
|
||||
});
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
for (const missing of preparedMissing) {
|
||||
updates.push({
|
||||
kind: "missing",
|
||||
filename: missing.filename,
|
||||
modifiedTime: missing.modifiedTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const nextToc = applySendBatchToToc(
|
||||
toc,
|
||||
updates,
|
||||
zipName,
|
||||
processedAt,
|
||||
) satisfies FileInfos;
|
||||
|
||||
const writtenFiles = await this.writeSendZip(zipName, zippedFiles, nextToc);
|
||||
const tocFilePath = this.plugin.backups.normalizePath(
|
||||
`${this.plugin.backupFolder}${this.plugin.sep}${InfoFile}`,
|
||||
);
|
||||
const tocSaved = await this.plugin.backups.writeTOC(
|
||||
tocFilePath,
|
||||
toArrayBuffer(
|
||||
new TextEncoder().encode(`\`\`\`\n${stringifyYaml(nextToc)}\n\`\`\`\n`),
|
||||
),
|
||||
);
|
||||
if (!tocSaved) {
|
||||
await this.rollbackWrittenZipFiles(writtenFiles);
|
||||
throw new Error(
|
||||
`TOC update failed after writing ${zipName}. ZIP files were rolled back when possible.`,
|
||||
);
|
||||
}
|
||||
|
||||
toc = nextToc;
|
||||
sentCount += updates.length;
|
||||
}
|
||||
|
||||
return sentCount;
|
||||
}
|
||||
|
||||
onClose() {
|
||||
if (this.component) {
|
||||
unmount(this.component);
|
||||
void unmount(this.component);
|
||||
this.component = undefined;
|
||||
}
|
||||
const { contentEl } = this;
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ export class PopOverSelectString extends FuzzySuggestModal<string> {
|
|||
}
|
||||
|
||||
onClose(): void {
|
||||
setTimeout(() => {
|
||||
window.setTimeout(() => {
|
||||
if (this.callback != undefined) {
|
||||
this.callback("");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
* Abstract class for storage accessors and its implementations.
|
||||
*/
|
||||
import type DiffZipBackupPlugin from "../main.ts";
|
||||
// Because of this is a type-only import.
|
||||
// eslint-disable-next-line import/no-nodejs-modules
|
||||
import type { promises } from "node:fs";
|
||||
import { OpenSSLCompat } from "octagonal-wheels/encryption";
|
||||
import { NormalVault } from "./StorageAccessor/NormalVault.ts";
|
||||
|
|
@ -23,6 +25,7 @@ export type FsAPI = {
|
|||
writeFile: typeof promises.writeFile;
|
||||
readFile: typeof promises.readFile;
|
||||
stat: typeof promises.stat;
|
||||
rm: typeof promises.rm;
|
||||
};
|
||||
|
||||
export const StorageAccessorTypes = {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export interface DiffZipBackupSettings {
|
|||
maxFilesInZip: number;
|
||||
maxTotalSizeInZip: number;
|
||||
performNextBackupOnMaxFiles: boolean;
|
||||
defaultDestructiveSyncActions: boolean;
|
||||
startBackupAtLaunch: boolean;
|
||||
startBackupAtLaunchType: AutoBackupType;
|
||||
includeHiddenFolder: boolean;
|
||||
|
|
@ -50,6 +51,7 @@ export const DEFAULT_SETTINGS: DiffZipBackupSettings = {
|
|||
bucket: "diffzip",
|
||||
maxFilesInZip: 100,
|
||||
performNextBackupOnMaxFiles: true,
|
||||
defaultDestructiveSyncActions: false,
|
||||
useCustomHttpHandler: false,
|
||||
passphraseOfFiles: "",
|
||||
passphraseOfZip: "",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { XByteArray, XDataView } from "./types.ts";
|
|||
export function* pieces(source: XByteArray, chunkSize: number): Generator<Uint8Array<ArrayBuffer>, void, void> {
|
||||
let offset = 0;
|
||||
while (offset < source.length) {
|
||||
yield source.slice(offset, offset + chunkSize) as Uint8Array<ArrayBuffer>;
|
||||
yield source.slice(offset, offset + chunkSize);
|
||||
offset += chunkSize;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
107
styles.css
107
styles.css
|
|
@ -95,8 +95,8 @@
|
|||
}
|
||||
|
||||
.op-add { background-color: var(--color-green); color: var(--text-on-accent); }
|
||||
.op-update { background-color: var(--color-blue); color: var(--text-on-accent); }
|
||||
.op-revert { background-color: var(--color-orange); color: var(--text-on-accent); }
|
||||
.op-updated { background-color: var(--color-blue); color: var(--text-on-accent); }
|
||||
.op-old { background-color: var(--color-orange); color: var(--text-on-accent); }
|
||||
.op-conflict { background-color: var(--color-red); color: var(--text-on-accent); }
|
||||
.op-delete { background-color: var(--color-red); color: var(--text-on-accent); }
|
||||
.op-extra { background-color: var(--text-muted); color: var(--background-primary); }
|
||||
|
|
@ -122,3 +122,106 @@
|
|||
border-top: 1px solid var(--background-modifier-border);
|
||||
padding-top: 0.75em;
|
||||
}
|
||||
|
||||
.diffzip-sync-table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.diffzip-sync-table,
|
||||
.diffzip-sync-table tbody,
|
||||
.diffzip-sync-table tr,
|
||||
.diffzip-sync-table td {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.diffzip-sync-table tr {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(8em, auto) minmax(0, 1fr);
|
||||
gap: 0.35em 0.5em;
|
||||
padding: 0.5em 0.6em;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.diffzip-sync-table td {
|
||||
padding: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.diffzip-sync-table .col-check {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
width: auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.diffzip-sync-table .col-op {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.diffzip-sync-table .col-path {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.diffzip-sync-table .col-zip,
|
||||
.diffzip-sync-table .col-mod {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.diffzip-sync-table .col-zip::before {
|
||||
content: "ZIP: ";
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.diffzip-sync-table .col-mod::before {
|
||||
content: "Modified: ";
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Radio action selector */
|
||||
.action-radio-group {
|
||||
all: unset;
|
||||
display: flex;
|
||||
gap: 0.4em;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-radio input[type="radio"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.action-emoji {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
border-radius: 4px;
|
||||
font-size: 1em;
|
||||
opacity: 0.5;
|
||||
transition: all 0.15s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.action-radio input[type="radio"]:checked + .action-emoji {
|
||||
opacity: 1;
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border-color: var(--interactive-accent);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.action-radio input[type="radio"]:hover + .action-emoji {
|
||||
opacity: 0.75;
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "es2017",
|
||||
"target": "es2019",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "bundler",
|
||||
|
|
@ -14,7 +14,8 @@
|
|||
"verbatimModuleSyntax": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"lib": ["DOM", "ES2017"]
|
||||
"lib": ["DOM", "es2019"]
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["**/*.test.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,15 @@ import { readFileSync, writeFileSync } from "fs";
|
|||
const targetVersion = process.env.npm_package_version;
|
||||
|
||||
// read minAppVersion from manifest.json and bump version to target version
|
||||
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||
const { minAppVersion } = manifest;
|
||||
manifest.version = targetVersion;
|
||||
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||
|
||||
// update versions.json with target version and minAppVersion from manifest.json
|
||||
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
|
||||
versions[targetVersion] = minAppVersion;
|
||||
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));
|
||||
// but only if the target version is not already in versions.json
|
||||
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
|
||||
if (!Object.values(versions).includes(minAppVersion)) {
|
||||
versions[targetVersion] = minAppVersion;
|
||||
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
|
||||
}
|
||||
Loading…
Reference in a new issue