feat: Lightweight Sync

fixed: new adjust to Community Lint
This commit is contained in:
vorotamoroz 2026-05-14 12:13:48 +01:00
parent bcc7cf433e
commit 6afc7efa7b
No known key found for this signature in database
GPG key ID: A51445513C5FCC34
34 changed files with 9259 additions and 4948 deletions

View file

@ -1,3 +0,0 @@
node_modules/
main.js
data.json

View file

@ -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
View file

@ -20,3 +20,7 @@ data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
coverage_out
coverage_dir
# env
.env

View file

@ -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
View 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
View 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
View 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
View file

@ -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() {

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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
View 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");
});

View file

@ -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) {

View file

@ -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();
});
}
}

View file

@ -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",

View file

@ -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();
}

View file

@ -24,7 +24,7 @@
)
);
let selectedTimestamp = $state(0);
// let selectedTimestamp = $state(0);
</script>
<div class="diffzip-list-row">

View file

@ -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(

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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.");
}

View file

@ -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
View 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
View 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;
}

View file

@ -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">

View file

@ -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;

View file

@ -202,7 +202,7 @@ export class PopOverSelectString extends FuzzySuggestModal<string> {
}
onClose(): void {
setTimeout(() => {
window.setTimeout(() => {
if (this.callback != undefined) {
this.callback("");
}

View file

@ -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 = {

View file

@ -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: "",

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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"]
}

View file

@ -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'));
}