Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Contributor setup, tests, architecture notes, and local real-Obsidian workflows

`Create Differential Backup` compares the current vault with `backupinfo.md` and writes a new ZIP only for changes.
Files under the configured backup folder and restore folder are skipped.
While a backup, restore, or selective-sync Fetch or Send operation is running, DiffZip requests a screen wake lock on supported devices. The request is best effort: the browser or operating system can deny or release it, and it does not keep DiffZip running in the background. DiffZip releases its request when the operation completes, is cancelled, or fails.
It records:

- new files
Expand Down
6 changes: 6 additions & 0 deletions docs/devs.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,9 @@ Real Modal rendering and dismissal use the local-only Obsidian harness documente
## Fancy Kit dependencies

`package.json` pins the Fancy Kit packages and `octagonal-wheels` to exact npm versions so the tested dependency set remains reproducible. Review and update the four versions together when adopting a newer contract. The plug-in kit declares an exact dependency on the matching `@vrtmrz/ui-interactions` release, and the lockfile records each package integrity hash.

## Screen wake lock

The plug-in owns one lifecycle-aware screen wake-lock manager. Differential backups, archive restore, and the Fetch and Send phases of selective sync use its closure-based runner, so normal completion, cancellation, and errors release their logical lease automatically. Overlapping and nested operations share the platform wake lock. Confirmation dialogues do not acquire a lease; restore protection starts only when archive input and Vault output begin.

The Screen Wake Lock API is best effort. Consumer workflows must continue when it is unavailable or rejected, and must not rely on it for background execution. Dispose the manager when the plug-in unloads. Keep the manager injectable through the focused helper in `src/wakeLock.ts`; App-free tests use that boundary instead of constructing the Obsidian plug-in.
28 changes: 28 additions & 0 deletions main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,16 @@ import { detectChangedFiles, planBatches, packBatches, type ArchivedBatch } from
import { delay } from "octagonal-wheels/promises";
import { createObsidianUi, type UiInteractions } from "@vrtmrz/obsidian-plugin-kit/ui";
import { confirmRestore } from "./src/restoreConfirmation.ts";
import { createDiffZipWakeLock, runWithDiffZipWakeLock, type DiffZipWakeLockLabel } from "./src/wakeLock.ts";

export default class DiffZipBackupPlugin extends Plugin {
settings!: DiffZipBackupSettings;
ui!: UiInteractions;
readonly operationWakeLock = createDiffZipWakeLock();

runWhileAwake<T>(label: DiffZipWakeLockLabel, task: () => T | PromiseLike<T>): Promise<T> {
return runWithDiffZipWakeLock(this.operationWakeLock, label, task);
}

get isMobile(): boolean {
// @ts-ignore
Expand Down Expand Up @@ -195,6 +201,12 @@ export default class DiffZipBackupPlugin extends Plugin {
}

async createZip(verbosity: boolean, onlyNew = false, skipDeleted: boolean = false) {
return await this.runWhileAwake("differential-backup", () =>
this.createZipWithoutWakeLock(verbosity, onlyNew, skipDeleted)
);
}

private async createZipWithoutWakeLock(verbosity: boolean, onlyNew: boolean, skipDeleted: boolean) {
const key = "proc-zip-process-" + Date.now();
const log = verbosity
? (msg: string, key?: string) => this.logWrite(msg, key)
Expand Down Expand Up @@ -399,6 +411,17 @@ export default class DiffZipBackupPlugin extends Plugin {
extractFiles: string | string[],
restoreAs: string | undefined = undefined,
restorePrefix: string = ""
): Promise<void> {
return await this.runWhileAwake("archive-restore", () =>
this.extractWithoutWakeLock(zipFile, extractFiles, restoreAs, restorePrefix)
);
}

private async extractWithoutWakeLock(
zipFile: string,
extractFiles: string | string[],
restoreAs: string | undefined,
restorePrefix: string
): Promise<void> {
const hasMultipleSupplied = Array.isArray(extractFiles);
const zipPath = this.backups.normalizePath(`${this.backupFolder}${this.sep}${zipFile}`);
Expand All @@ -421,6 +444,7 @@ export default class DiffZipBackupPlugin extends Plugin {
}
if (files.length == 0) {
this.logMessage("Archived ZIP files were not found!");
return;
}
const restored = [] as string[];

Expand Down Expand Up @@ -457,6 +481,7 @@ export default class DiffZipBackupPlugin extends Plugin {
extractor.addZippedContent(chunk);
}
}
await extractor.finalise();
}

async selectAndRestore() {
Expand Down Expand Up @@ -786,6 +811,9 @@ export default class DiffZipBackupPlugin extends Plugin {
// console.dir(zipFileMap);
}
async onload() {
this.register(() => {
void this.operationWakeLock.dispose();
});
this.ui = createObsidianUi(this.app);
await this.loadSettings();
if ("backupFolder" in this.settings) {
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "diffzip",
"name": "Differential ZIP Backup",
"version": "0.1.8",
"version": "0.1.9-wakelock.1",
"minAppVersion": "1.8.7",
"description": "Back our vault up with lesser storage.",
"author": "vorotamoroz",
Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "diffzip",
"version": "0.1.8",
"version": "0.1.9-wakelock.1",
"description": "Differential ZIP Backup",
"main": "main.js",
"scripts": {
Expand All @@ -12,6 +12,7 @@
"test:ui:watch": "vitest --config vitest.config.ts",
"check:e2e:obsidian": "tsc -p test/e2e-obsidian/tsconfig.json",
"test:e2e:obsidian:restore-confirmation": "npm run build && tsx test/e2e-obsidian/restore-confirmation.mts",
"test:e2e:obsidian:wake-lock": "npm run build && tsx test/e2e-obsidian/wake-lock.mts",
"version": "node version-bump.mjs && git add manifest.json versions.json",
"pretty": "npm run prettyNoWrite -- --write --log-level error",
"prettyCheck": "npm run prettyNoWrite -- --check",
Expand Down Expand Up @@ -61,6 +62,6 @@
"@vrtmrz/obsidian-plugin-kit": "0.1.0",
"@vrtmrz/ui-interactions": "0.1.0",
"fflate": "^0.8.2",
"octagonal-wheels": "0.1.48"
"octagonal-wheels": "0.1.51"
}
}
55 changes: 47 additions & 8 deletions src/Archive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,7 @@ Deno.test("Archiver + Extractor: round-trip a single text file", async () => {
},
);
extractor.addZippedContent(zipData, true);
// Give async callbacks time to settle
await new Promise((res) => setTimeout(res, 100));
await extractor.finalise();

assertEquals(extracted["note.md"], "hello, world", "Extracted content must match original");
});
Expand All @@ -63,7 +62,7 @@ Deno.test("Archiver + Extractor: round-trip multiple files", async () => {
},
);
extractor.addZippedContent(zipData, true);
await new Promise((res) => setTimeout(res, 100));
await extractor.finalise();

for (const [path, text] of Object.entries(files)) {
assertEquals(extracted[path], text, `Extracted content of ${path} must match original`);
Expand All @@ -84,7 +83,7 @@ Deno.test("Extractor: filter function skips unwanted files", async () => {
},
);
extractor.addZippedContent(zipData, true);
await new Promise((res) => setTimeout(res, 100));
await extractor.finalise();

assert("keep.md" in extracted, "keep.md must be extracted");
assert(!("skip.md" in extracted), "skip.md must be skipped");
Expand Down Expand Up @@ -132,7 +131,7 @@ Deno.test("Archiver: large file triggers multi-chunk path and progress callback"
},
);
extractor.addZippedContent(zipData, true);
await new Promise((res) => setTimeout(res, 500));
await extractor.finalise();

assert("large.bin" in extracted, "large.bin must be extracted");
assertEquals(extracted["large.bin"].length, SIZE, "Extracted size must match original");
Expand All @@ -158,9 +157,49 @@ Deno.test("Extractor: finalise() correctly ends streamed zip input", async () =>
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));
await extractor.finalise();

assertEquals(extracted["stream.md"], "streamed content", "Streamed extraction via finalise() must match original");
});

Deno.test("Extractor: finalise() waits for an asynchronous extraction callback", async () => {
const archiver = new Archiver();
archiver.addTextFile("delayed content", "delayed.md");
const zipData = await archiver.finalize();

let callbackFinished = false;
const extractor = new Extractor(
() => true,
async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
callbackFinished = true;
},
);
extractor.addZippedContent(zipData, true);
await extractor.finalise();

assert(callbackFinished, "finalise() must not resolve before the extraction callback finishes");
});

Deno.test("Extractor: finalise() propagates extraction callback errors", async () => {
const archiver = new Archiver();
archiver.addTextFile("content", "failure.md");
const zipData = await archiver.finalize();

const expected = new Error("write failed");
const extractor = new Extractor(
() => true,
async () => {
throw expected;
},
);
extractor.addZippedContent(zipData, true);

let actual: unknown;
try {
await extractor.finalise();
} catch (error: unknown) {
actual = error;
}
assert(actual === expected, "finalise() must propagate the extraction callback error");
});
79 changes: 56 additions & 23 deletions src/Archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ export class Extractor {
_zipFile: fflate.Unzip;
_isFileShouldBeExtracted: (file: fflate.UnzipFile) => boolean | Promise<boolean>;
_onExtracted: (filename: string, content: XByteArray) => Promise<void>;
_pendingFiles = new Set<Promise<void>>();
_failures: unknown[] = [];
_inputFinalised = false;

constructor(isFileShouldBeExtracted: Extractor["_isFileShouldBeExtracted"], callback: Extractor["_onExtracted"]) {
const unzipper = new fflate.Unzip();
Expand All @@ -117,34 +120,64 @@ export class Extractor {
this._isFileShouldBeExtracted = isFileShouldBeExtracted;
this._onExtracted = callback;

const onFile = async (file: fflate.UnzipFile) => {
if (await this._isFileShouldBeExtracted(file)) {
const data: XByteArray[] = [];
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(new Uint8Array(dat));

if (isFinal) {
const total = new Blob(data, { type: "application/octet-stream" });
const result = new Uint8Array(await total.arrayBuffer());
await this._onExtracted(file.name, result);
}
};
file.ondata = (err, dat, isFinal) => void onData(err, dat, isFinal);
file.start();
}
};
unzipper.onfile = (file) => void onFile(file);
unzipper.onfile = (file) => this.trackFile(file);
}

addZippedContent(data: XByteArray, isFinal = false) {
this._zipFile.push(data, isFinal);
this._inputFinalised ||= isFinal;
}

finalise() {
this._zipFile.push(new Uint8Array(), true);
/** Finalise the ZIP input and wait for every selected file callback to finish. */
async finalise(): Promise<void> {
if (!this._inputFinalised) {
this._zipFile.push(new Uint8Array(), true);
this._inputFinalised = true;
}
while (this._pendingFiles.size > 0) {
await Promise.all(this._pendingFiles);
}
if (this._failures.length > 0) {
throw this._failures[0];
}
}

private trackFile(file: fflate.UnzipFile): void {
let tracked: Promise<void>;
tracked = this.extractFile(file)
.catch((error: unknown) => {
this._failures.push(error);
})
.finally(() => {
this._pendingFiles.delete(tracked);
});
this._pendingFiles.add(tracked);
}

private async extractFile(file: fflate.UnzipFile): Promise<void> {
if (!(await this._isFileShouldBeExtracted(file))) {
return;
}
const data: XByteArray[] = [];
await new Promise<void>((resolve, reject) => {
file.ondata = (err, dat, isFinal) => {
if (err) {
reject(err);
return;
}
if (dat && dat.length > 0) {
data.push(new Uint8Array(dat));
}
if (!isFinal) {
return;
}
const total = new Blob(data, { type: "application/octet-stream" });
void total
.arrayBuffer()
.then((buffer) => this._onExtracted(file.name, new Uint8Array(buffer)))
.then(resolve, reject);
};
file.start();
});
}
}
Loading
Loading