mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-21 18:17:05 +00:00
Refactor startup lifecycle into service features
This commit is contained in:
@@ -1,107 +0,0 @@
|
||||
import type { LiveSyncCore } from "@/main";
|
||||
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { AbstractModule } from "@/modules/AbstractModule";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { copyFileDatabaseInfo } from "@/serviceFeatures/fileDatabaseInfo";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
// Separated Module for basic menu commands, which are not related to obsidian specific features. It is expected to be used in other platforms with minimal changes.
|
||||
// However, it is odd that it has here at all; it really ought to be in each respective feature. It will likely be moved eventually. Until now, addCommand pointed to Obsidian's version.
|
||||
export class ModuleBasicMenu extends AbstractModule {
|
||||
_everyOnloadStart(): Promise<boolean> {
|
||||
this.addCommand({
|
||||
id: "livesync-replicate",
|
||||
name: $msg("Sync now"),
|
||||
callback: async () => {
|
||||
await this.services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-dump",
|
||||
name: $msg("Copy database information for the active file"),
|
||||
checkCallback: (checking) => {
|
||||
const file = this.services.vault.getActiveFilePath();
|
||||
if (!file) return false;
|
||||
if (!checking) {
|
||||
fireAndForget(() => copyFileDatabaseInfo(this.core, file));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-toggle",
|
||||
name: "Toggle LiveSync",
|
||||
callback: async () => {
|
||||
if (this.settings.liveSync) {
|
||||
this.settings.liveSync = false;
|
||||
this._log("LiveSync Disabled.", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
this.settings.liveSync = true;
|
||||
this._log("LiveSync Enabled.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
await this.services.control.applySettings();
|
||||
await this.services.setting.saveSettingData();
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-suspendall",
|
||||
name: "Toggle All Sync.",
|
||||
callback: async () => {
|
||||
if (this.services.appLifecycle.isSuspended()) {
|
||||
this.services.appLifecycle.setSuspended(false);
|
||||
this._log("Self-hosted LiveSync resumed", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
this.services.appLifecycle.setSuspended(true);
|
||||
this._log("Self-hosted LiveSync suspended", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
await this.services.control.applySettings();
|
||||
await this.services.setting.saveSettingData();
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "livesync-scan-files",
|
||||
name: "Scan storage and database again",
|
||||
checkCallback: (checking) => {
|
||||
if (!this.settings.useAdvancedMode) return false;
|
||||
if (!checking) {
|
||||
fireAndForget(() => this.services.vault.scanVault(true));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "livesync-runbatch",
|
||||
name: $msg("Apply pending changes now"),
|
||||
callback: async () => {
|
||||
await this.services.fileProcessing.commitPendingFileEvents();
|
||||
},
|
||||
});
|
||||
|
||||
// TODO, Replicator is possibly one of features. It should be moved to features.
|
||||
this.addCommand({
|
||||
id: "livesync-abortsync",
|
||||
name: "Abort synchronization immediately",
|
||||
checkCallback: (checking) => {
|
||||
if (!this.settings.useAdvancedMode) return false;
|
||||
if (!checking) {
|
||||
fireAndForget(() => this.services.replication.stopActiveTransfer());
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Command } from "@/deps";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { ModuleBasicMenu } from "./ModuleBasicMenu";
|
||||
|
||||
type RegisteredCommand = Command & {
|
||||
checkCallback?: (checking: boolean) => boolean | void;
|
||||
};
|
||||
|
||||
function createFixture() {
|
||||
const commands: RegisteredCommand[] = [];
|
||||
const settings = {
|
||||
liveSync: false,
|
||||
useAdvancedMode: false,
|
||||
enableDebugTools: false,
|
||||
};
|
||||
const services = {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn((command: RegisteredCommand) => {
|
||||
commands.push(command);
|
||||
return command;
|
||||
}),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
replication: {
|
||||
replicateUserInitiated: vi.fn(async () => ({ status: "completed" as const })),
|
||||
stopActiveTransfer: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
vault: {
|
||||
getActiveFilePath: vi.fn((): string | null => "note.md"),
|
||||
scanVault: vi.fn(async () => undefined),
|
||||
},
|
||||
control: {
|
||||
applySettings: vi.fn(async () => undefined),
|
||||
},
|
||||
setting: {
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
},
|
||||
appLifecycle: {
|
||||
isSuspended: vi.fn(() => false),
|
||||
setSuspended: vi.fn(),
|
||||
},
|
||||
fileProcessing: {
|
||||
commitPendingFileEvents: vi.fn(async () => true),
|
||||
},
|
||||
UI: {
|
||||
promptCopyToClipboard: vi.fn(async (_title: string, _value: string) => true),
|
||||
},
|
||||
path: {
|
||||
path2id: vi.fn(async () => "f:note"),
|
||||
},
|
||||
};
|
||||
const core = {
|
||||
settings,
|
||||
_services: services,
|
||||
services,
|
||||
localDatabase: {
|
||||
getDBEntry: vi.fn(async () => false),
|
||||
localDatabase: {
|
||||
get: vi.fn(async () => ({
|
||||
_id: "f:note",
|
||||
_rev: "2-current",
|
||||
_conflicts: [],
|
||||
path: "note.md",
|
||||
ctime: 100,
|
||||
mtime: 200,
|
||||
size: 12,
|
||||
type: "plain",
|
||||
children: ["h:private-chunk-id"],
|
||||
eden: {},
|
||||
})),
|
||||
},
|
||||
getDBEntryMeta: vi.fn(async () => ({
|
||||
_id: "f:note",
|
||||
_rev: "2-current",
|
||||
_conflicts: [],
|
||||
path: "note.md",
|
||||
ctime: 100,
|
||||
mtime: 200,
|
||||
size: 12,
|
||||
type: "plain",
|
||||
datatype: "plain",
|
||||
data: "",
|
||||
children: ["h:private-chunk-id"],
|
||||
eden: {},
|
||||
})),
|
||||
allDocsRaw: vi.fn(async () => ({
|
||||
rows: [{ id: "h:private-chunk-id", key: "h:private-chunk-id", value: { rev: "1-chunk" } }],
|
||||
})),
|
||||
},
|
||||
storageAccess: {
|
||||
isExistsIncludeHidden: vi.fn(async () => true),
|
||||
statHidden: vi.fn(async () => ({ ctime: 100, mtime: 200, size: 12, type: "file" })),
|
||||
},
|
||||
replicator: {
|
||||
terminateSync: vi.fn(),
|
||||
},
|
||||
};
|
||||
const module = new ModuleBasicMenu(core as never);
|
||||
|
||||
return {
|
||||
commands,
|
||||
core,
|
||||
module,
|
||||
services,
|
||||
settings,
|
||||
getCommand(id: string) {
|
||||
const command = commands.find((candidate) => candidate.id === id);
|
||||
expect(command, `command ${id}`).toBeDefined();
|
||||
return command!;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ModuleBasicMenu command palette", () => {
|
||||
it("uses clear user-facing names without changing the established command IDs", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
|
||||
expect(fixture.getCommand("livesync-replicate").name).toBe("Sync now");
|
||||
expect(fixture.getCommand("livesync-runbatch").name).toBe("Apply pending changes now");
|
||||
});
|
||||
|
||||
it("keeps Sync now progress quiet while retaining failure-recovery authority", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
await fixture.getCommand("livesync-replicate").callback?.();
|
||||
|
||||
expect(fixture.services.replication.replicateUserInitiated).toHaveBeenCalledWith({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps maintenance commands out of the normal palette", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
|
||||
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(false);
|
||||
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(false);
|
||||
|
||||
fixture.settings.useAdvancedMode = true;
|
||||
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(true);
|
||||
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(true);
|
||||
});
|
||||
|
||||
it("routes an explicit stop through the active provider capability", async () => {
|
||||
const fixture = createFixture();
|
||||
fixture.settings.useAdvancedMode = true;
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
|
||||
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(false)).toBe(true);
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.services.replication.stopActiveTransfer).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(fixture.core.replicator.terminateSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps active-file database information available and opens it in a copy dialogue", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
|
||||
const command = fixture.getCommand("livesync-dump");
|
||||
expect(command.name).toBe("Copy database information for the active file");
|
||||
expect(command.checkCallback?.(true)).toBe(true);
|
||||
|
||||
command.checkCallback?.(false);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.services.UI.promptCopyToClipboard).toHaveBeenCalledOnce();
|
||||
});
|
||||
const [title, report] = fixture.services.UI.promptCopyToClipboard.mock.calls[0];
|
||||
expect(title).toBe("Database information for note.md");
|
||||
expect(report).toContain("note.md");
|
||||
expect(report).toContain("2-current");
|
||||
expect(report).toContain("h:private-chunk-id");
|
||||
expect(report).toContain("1-chunk");
|
||||
expect(fixture.core.localDatabase.getDBEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides the active-file database report when no file is active", async () => {
|
||||
const fixture = createFixture();
|
||||
fixture.services.vault.getActiveFilePath.mockReturnValue(null);
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
|
||||
expect(fixture.getCommand("livesync-dump").checkCallback?.(true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,346 +0,0 @@
|
||||
import {
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
Logger,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { EVENT_REQUEST_RUN_DOCTOR, EVENT_REQUEST_RUN_FIX_INCOMPLETE, eventHub } from "@/common/events.ts";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { performDoctorConsultation, RebuildOptions } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
|
||||
import { isValidPath } from "@/common/utils.ts";
|
||||
import { isMetaEntry } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
isDeletedEntry,
|
||||
isDocContentSame,
|
||||
isLoadedEntry,
|
||||
readAsBlob,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { countCompromisedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { SetupManager } from "@/modules/features/SetupManager.ts";
|
||||
import { showOnboardingInvitation } from "@/serviceFeatures/setupObsidian/setupManagerHandlers.ts";
|
||||
import {
|
||||
runConfiguredStartupLifecycle,
|
||||
runStartupEntryLifecycle,
|
||||
} from "@/serviceFeatures/configuredStartupLifecycle.ts";
|
||||
import { disableLegacyBulkChunkPreSend } from "@/common/compatibilitySettings.ts";
|
||||
|
||||
type ErrorInfo = {
|
||||
path: string;
|
||||
recordedSize: number;
|
||||
actualSize: number;
|
||||
storageSize: number;
|
||||
contentMatched: boolean;
|
||||
isConflicted?: boolean;
|
||||
};
|
||||
|
||||
const INCOMPLETE_DOCUMENT_NOTICE_GROUP = "startup-integrity-check";
|
||||
|
||||
interface CompromisedChunkCounter {
|
||||
countCompromisedChunks(): Promise<number | boolean>;
|
||||
}
|
||||
|
||||
function hasCompromisedChunkCounter(value: object | undefined): value is CompromisedChunkCounter {
|
||||
return (
|
||||
value !== undefined && "countCompromisedChunks" in value && typeof value.countCompromisedChunks === "function"
|
||||
);
|
||||
}
|
||||
|
||||
export class ModuleMigration extends AbstractModule<LiveSyncCore> {
|
||||
constructor(
|
||||
core: LiveSyncCore,
|
||||
private readonly waitForCompatibilityReview: () => Promise<void> = () => Promise.resolve()
|
||||
) {
|
||||
super(core);
|
||||
}
|
||||
|
||||
async migrateUsingDoctor(skipRebuild: boolean = false, activateReason = "updated", forceRescan = false) {
|
||||
const { shouldRebuild, shouldRebuildLocal, isModified, settings } = await performDoctorConsultation(
|
||||
{
|
||||
confirm: this.core.confirm,
|
||||
translate: this.services.context.translate,
|
||||
},
|
||||
this.settings,
|
||||
{
|
||||
localRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
|
||||
remoteRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
|
||||
activateReason,
|
||||
forceRescan,
|
||||
}
|
||||
);
|
||||
if (isModified) {
|
||||
this.settings = settings;
|
||||
await this.saveSettings();
|
||||
}
|
||||
if (!skipRebuild) {
|
||||
if (shouldRebuild) {
|
||||
await this.core.rebuilder.scheduleRebuild();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
} else if (shouldRebuildLocal) {
|
||||
await this.core.rebuilder.scheduleFetch();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async migrateDisableBulkSend() {
|
||||
if (disableLegacyBulkChunkPreSend(this.settings)) {
|
||||
this._log($msg("moduleMigration.logBulkSendCorrupted"), LOG_LEVEL_NOTICE);
|
||||
await this.saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
initialMessage() {
|
||||
const manager = this.core.getModule(SetupManager);
|
||||
showOnboardingInvitation(this.core, manager);
|
||||
}
|
||||
|
||||
async hasIncompleteDocs(force: boolean = false): Promise<boolean> {
|
||||
const incompleteDocsChecked = (await this.core.kvDB.get<boolean>("checkIncompleteDocs")) || false;
|
||||
if (incompleteDocsChecked && !force) {
|
||||
this._log("Incomplete docs check already done, skipping.", LOG_LEVEL_VERBOSE);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
const noticeGroups = this.core.services.context.noticeGroups;
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "checking", {
|
||||
message: "Checking for incomplete documents...",
|
||||
});
|
||||
this._log("Checking for incomplete documents...", LOG_LEVEL_VERBOSE);
|
||||
|
||||
try {
|
||||
const errorFiles = [] as ErrorInfo[];
|
||||
for await (const metaDoc of this.localDatabase.findAllNormalDocs({ conflicts: true })) {
|
||||
const path = this.getPath(metaDoc);
|
||||
|
||||
if (!isValidPath(path)) {
|
||||
continue;
|
||||
}
|
||||
if (!(await this.services.vault.isTargetFile(path))) {
|
||||
continue;
|
||||
}
|
||||
if (!isMetaEntry(metaDoc)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const doc = await this.localDatabase.getDBEntryFromMeta(metaDoc);
|
||||
if (!doc || !isLoadedEntry(doc)) {
|
||||
continue;
|
||||
}
|
||||
if (isDeletedEntry(doc)) {
|
||||
continue;
|
||||
}
|
||||
const isConflicted = metaDoc?._conflicts && metaDoc._conflicts.length > 0;
|
||||
|
||||
let storageFileContent;
|
||||
try {
|
||||
storageFileContent = await this.core.storageAccess.readHiddenFileBinary(path);
|
||||
} catch (e) {
|
||||
Logger(`Failed to read file ${path}: Possibly unprocessed or missing`);
|
||||
Logger(e, LOG_LEVEL_VERBOSE);
|
||||
continue;
|
||||
}
|
||||
// const storageFileBlob = createBlob(storageFileContent);
|
||||
const sizeOnStorage = storageFileContent.byteLength;
|
||||
const recordedSize = doc.size;
|
||||
const docBlob = readAsBlob(doc);
|
||||
const actualSize = docBlob.size;
|
||||
if (
|
||||
recordedSize !== actualSize ||
|
||||
sizeOnStorage !== actualSize ||
|
||||
sizeOnStorage !== recordedSize ||
|
||||
isConflicted
|
||||
) {
|
||||
const contentMatched = await isDocContentSame(doc.data, storageFileContent);
|
||||
errorFiles.push({
|
||||
path,
|
||||
recordedSize,
|
||||
actualSize,
|
||||
storageSize: sizeOnStorage,
|
||||
contentMatched,
|
||||
isConflicted,
|
||||
});
|
||||
Logger(
|
||||
`Size mismatch for ${path}: ${recordedSize} (DB Recorded) , ${actualSize} (DB Stored) , ${sizeOnStorage} (Storage Stored), ${contentMatched ? "Content Matched" : "Content Mismatched"} ${isConflicted ? "Conflicted" : "Not Conflicted"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (errorFiles.length == 0) {
|
||||
Logger("No size mismatches found", LOG_LEVEL_INFO);
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
|
||||
message: "No size mismatches found",
|
||||
});
|
||||
await this.core.kvDB.set("checkIncompleteDocs", true);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
Logger(`Found ${errorFiles.length} size mismatches`, LOG_LEVEL_INFO);
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
|
||||
message: `Found ${errorFiles.length} size mismatches`,
|
||||
});
|
||||
// We have to repair them following rules and situations:
|
||||
// A. DB Recorded != DB Stored
|
||||
// A.1. DB Recorded == Storage Stored
|
||||
// Possibly recoverable from storage. Just overwrite the DB content with storage content.
|
||||
// A.2. Neither
|
||||
// Probably it cannot be resolved on this device. Even if the storage content is larger than DB Recorded, it possibly corrupted.
|
||||
// We do not fix it automatically. Leave it as is. Possibly other device can do this.
|
||||
// B. DB Recorded == DB Stored , < Storage Stored
|
||||
// Very fragile, if DB Recorded size is less than Storage Stored size, we possibly repair the content (The issue was `unexpectedly shortened file`).
|
||||
// We do not fix it automatically, but it will be automatically overwritten in other process.
|
||||
// C. DB Recorded == DB Stored , > Storage Stored
|
||||
// Probably restored by the user by resolving A or B on other device, We should overwrite the storage
|
||||
// Also do not fix it automatically. It should be overwritten by replication.
|
||||
const recoverable = errorFiles.filter((e) => {
|
||||
return e.recordedSize === e.storageSize && !e.isConflicted;
|
||||
});
|
||||
const unrecoverable = errorFiles.filter((e) => {
|
||||
return e.recordedSize !== e.storageSize || e.isConflicted;
|
||||
});
|
||||
const fileInfo = (e: (typeof errorFiles)[0]) => {
|
||||
return `${e.path} (M: ${e.recordedSize}, A: ${e.actualSize}, S: ${e.storageSize}) ${e.isConflicted ? "(Conflicted)" : ""}`;
|
||||
};
|
||||
const messageUnrecoverable =
|
||||
unrecoverable.length > 0
|
||||
? $msg("moduleMigration.fix0256.messageUnrecoverable", {
|
||||
filesNotRecoverable: unrecoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
|
||||
})
|
||||
: "";
|
||||
|
||||
const message = $msg("moduleMigration.fix0256.message", {
|
||||
files: recoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
|
||||
messageUnrecoverable,
|
||||
});
|
||||
const CHECK_IT_LATER = $msg("moduleMigration.fix0256.buttons.checkItLater");
|
||||
const FIX = $msg("moduleMigration.fix0256.buttons.fix");
|
||||
const DISMISS = $msg("moduleMigration.fix0256.buttons.DismissForever");
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(message, [CHECK_IT_LATER, FIX, DISMISS], {
|
||||
title: $msg("moduleMigration.fix0256.title"),
|
||||
defaultAction: CHECK_IT_LATER,
|
||||
});
|
||||
if (ret == FIX) {
|
||||
for (const file of recoverable) {
|
||||
// Overwrite the database with the files on the storage
|
||||
const stubFile = await this.core.storageAccess.getFileStub(file.path);
|
||||
if (stubFile == null) {
|
||||
Logger(`Could not find stub file for ${file.path}`, LOG_LEVEL_NOTICE);
|
||||
continue;
|
||||
}
|
||||
|
||||
stubFile.stat.mtime = Date.now();
|
||||
const result = await this.core.fileHandler.storeFileToDB(stubFile, true, false);
|
||||
if (result) {
|
||||
Logger(`Successfully restored ${file.path} from storage`);
|
||||
} else {
|
||||
Logger(`Failed to restore ${file.path} from storage`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
}
|
||||
} else if (ret === DISMISS) {
|
||||
// User chose to dismiss the issue
|
||||
await this.core.kvDB.set("checkIncompleteDocs", true);
|
||||
}
|
||||
|
||||
return Promise.resolve(true);
|
||||
} catch (error) {
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
|
||||
message: "The incomplete document check could not be completed.",
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
noticeGroups.finish(INCOMPLETE_DOCUMENT_NOTICE_GROUP);
|
||||
}
|
||||
}
|
||||
|
||||
async hasCompromisedChunks(): Promise<boolean> {
|
||||
Logger(`Checking for compromised chunks...`, LOG_LEVEL_VERBOSE);
|
||||
if (!this.settings.encrypt) {
|
||||
// If not encrypted, we do not need to check for compromised chunks.
|
||||
return true;
|
||||
}
|
||||
// Check local database for compromised chunks
|
||||
const localCompromised = await countCompromisedChunks(this.localDatabase.localDatabase);
|
||||
const remote = this.services.replicator.getActiveReplicator();
|
||||
const remoteCompromised =
|
||||
this.services.API.isOnline && hasCompromisedChunkCounter(remote)
|
||||
? await remote.countCompromisedChunks()
|
||||
: 0;
|
||||
if (localCompromised === false) {
|
||||
Logger(`Failed to count compromised chunks in local database`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (remoteCompromised === false) {
|
||||
Logger(`Failed to count compromised chunks in remote database`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (remoteCompromised === 0 && localCompromised === 0) {
|
||||
return true;
|
||||
}
|
||||
Logger(
|
||||
`Found compromised chunks : ${localCompromised} in local, ${remoteCompromised} in remote`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
const title = $msg("moduleMigration.insecureChunkExist.title");
|
||||
const msg = $msg("moduleMigration.insecureChunkExist.message");
|
||||
const REBUILD = $msg("moduleMigration.insecureChunkExist.buttons.rebuild");
|
||||
const FETCH = $msg("moduleMigration.insecureChunkExist.buttons.fetch");
|
||||
const DISMISS = $msg("moduleMigration.insecureChunkExist.buttons.later");
|
||||
const buttons = [REBUILD, FETCH, DISMISS];
|
||||
if (remoteCompromised != 0) {
|
||||
buttons.splice(buttons.indexOf(FETCH), 1);
|
||||
}
|
||||
const result = await this.core.confirm.askSelectStringDialogue(msg, buttons, {
|
||||
title,
|
||||
defaultAction: DISMISS,
|
||||
timeout: 0,
|
||||
});
|
||||
if (result === REBUILD) {
|
||||
// Rebuild the database
|
||||
await this.core.rebuilder.scheduleRebuild();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
} else if (result === FETCH) {
|
||||
// Fetch the latest data from remote
|
||||
await this.core.rebuilder.scheduleFetch();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
} else {
|
||||
// User chose to dismiss the issue
|
||||
this._log($msg("moduleMigration.insecureChunkExist.laterMessage"), LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async _everyOnFirstInitialize(): Promise<boolean> {
|
||||
return await runConfiguredStartupLifecycle({
|
||||
databaseReady: this.localDatabase.isReady,
|
||||
reportDatabaseNotReady: () => this._log($msg("moduleMigration.logLocalDatabaseNotReady"), LOG_LEVEL_NOTICE),
|
||||
hasCompromisedChunks: () => this.hasCompromisedChunks(),
|
||||
hasIncompleteDocuments: () => this.hasIncompleteDocs(),
|
||||
waitForCompatibilityReview: () => this.waitForCompatibilityReview(),
|
||||
runDoctor: () => this.migrateUsingDoctor(false),
|
||||
migrateBulkSend: () => this.migrateDisableBulkSend(),
|
||||
});
|
||||
}
|
||||
_everyOnLayoutReady(): Promise<boolean> {
|
||||
const shouldInitialiseDatabase = runStartupEntryLifecycle({
|
||||
configured: this.settings.isConfigured === true,
|
||||
inviteToOnboarding: () => this.initialMessage(),
|
||||
});
|
||||
if (!shouldInitialiseDatabase) return Promise.resolve(false);
|
||||
eventHub.onEvent(EVENT_REQUEST_RUN_DOCTOR, async (reason) => {
|
||||
await this.migrateUsingDoctor(false, reason, true);
|
||||
});
|
||||
eventHub.onEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE, async () => {
|
||||
await this.hasIncompleteDocs(true);
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
super.onBindFunction(core, services);
|
||||
services.appLifecycle.onLayoutReady.addHandler(this._everyOnLayoutReady.bind(this));
|
||||
services.appLifecycle.onFirstInitialise.addHandler(this._everyOnFirstInitialize.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/modules/features/SetupManager.ts", () => ({
|
||||
SetupManager: class SetupManager {},
|
||||
}));
|
||||
vi.mock("@/deps.ts", () => ({}));
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
isValidPath: () => true,
|
||||
}));
|
||||
|
||||
import { ModuleMigration } from "./ModuleMigration.ts";
|
||||
|
||||
async function* noDocuments() {
|
||||
return;
|
||||
}
|
||||
|
||||
async function* failedDocumentScan() {
|
||||
throw new Error("scan failed");
|
||||
}
|
||||
|
||||
function createMigration(
|
||||
findAllNormalDocs: typeof noDocuments | typeof failedDocumentScan = noDocuments,
|
||||
settings = { sendChunksBulk: false, sendChunksBulkMaxSize: 1 }
|
||||
) {
|
||||
const noticeGroups = {
|
||||
setItem: vi.fn(),
|
||||
finish: vi.fn(() => true),
|
||||
};
|
||||
const services = {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
context: { noticeGroups },
|
||||
setting: { saveSettingData: vi.fn(async () => undefined) },
|
||||
vault: { isTargetFile: vi.fn(async () => true) },
|
||||
path: { getPath: vi.fn() },
|
||||
};
|
||||
const core = {
|
||||
_services: services,
|
||||
services,
|
||||
kvDB: {
|
||||
get: vi.fn(async () => false),
|
||||
set: vi.fn(async () => undefined),
|
||||
},
|
||||
localDatabase: { findAllNormalDocs },
|
||||
storageAccess: {},
|
||||
settings,
|
||||
};
|
||||
return {
|
||||
migration: new ModuleMigration(core as never),
|
||||
noticeGroups,
|
||||
saveSettingData: services.setting.saveSettingData,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ModuleMigration obsolete-setting migration", () => {
|
||||
it("persists the removal of an enabled automatic bulk chunk pre-send setting", async () => {
|
||||
const settings = { sendChunksBulk: true, sendChunksBulkMaxSize: 16 };
|
||||
const { migration, saveSettingData } = createMigration(noDocuments, settings);
|
||||
|
||||
await migration.migrateDisableBulkSend();
|
||||
|
||||
expect(settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 1 });
|
||||
expect(saveSettingData).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not persist an already disabled automatic bulk chunk pre-send setting", async () => {
|
||||
const settings = { sendChunksBulk: false, sendChunksBulkMaxSize: 16 };
|
||||
const { migration, saveSettingData } = createMigration(noDocuments, settings);
|
||||
|
||||
await migration.migrateDisableBulkSend();
|
||||
|
||||
expect(settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 16 });
|
||||
expect(saveSettingData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleMigration incomplete-document notice", () => {
|
||||
it("keeps the check and its result in one persistent named group", async () => {
|
||||
const { migration, noticeGroups } = createMigration();
|
||||
|
||||
await expect(migration.hasIncompleteDocs()).resolves.toBe(true);
|
||||
|
||||
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(1, "startup-integrity-check", "checking", {
|
||||
message: "Checking for incomplete documents...",
|
||||
});
|
||||
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(2, "startup-integrity-check", "result", {
|
||||
message: "No size mismatches found",
|
||||
});
|
||||
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
|
||||
});
|
||||
|
||||
it("finishes the group with a failure result when the scan throws", async () => {
|
||||
const { migration, noticeGroups } = createMigration(failedDocumentScan);
|
||||
|
||||
await expect(migration.hasIncompleteDocs()).rejects.toThrow("scan failed");
|
||||
|
||||
expect(noticeGroups.setItem).toHaveBeenLastCalledWith("startup-integrity-check", "result", {
|
||||
message: "The incomplete document check could not be completed.",
|
||||
});
|
||||
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { addIcon } from "@/deps.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
// Obsidian specific menu commands.
|
||||
export class ModuleObsidianMenu extends AbstractModule {
|
||||
_everyOnloadStart(): Promise<boolean> {
|
||||
// UI
|
||||
addIcon(
|
||||
"replicate",
|
||||
`<g transform="matrix(1.15 0 0 1.15 -8.31 -9.52)" fill="currentColor" fill-rule="evenodd">
|
||||
<path d="m85 22.2c-0.799-4.74-4.99-8.37-9.88-8.37-0.499 0-1.1 0.101-1.6 0.101-2.4-3.03-6.09-4.94-10.3-4.94-6.09 0-11.2 4.14-12.8 9.79-5.59 1.11-9.78 6.05-9.78 12 0 6.76 5.39 12.2 12 12.2h29.9c5.79 0 10.1-4.74 10.1-10.6 0-4.84-3.29-8.88-7.68-10.2zm-2.99 14.7h-29.5c-2.3-0.202-4.29-1.51-5.29-3.53-0.899-2.12-0.699-4.54 0.698-6.46 1.2-1.61 2.99-2.52 4.89-2.52 0.299 0 0.698 0 0.998 0.101l1.8 0.303v-2.02c0-3.63 2.4-6.76 5.89-7.57 0.599-0.101 1.2-0.202 1.8-0.202 2.89 0 5.49 1.62 6.79 4.24l0.598 1.21 1.3-0.504c0.599-0.202 1.3-0.303 2-0.303 1.3 0 2.5 0.404 3.59 1.11 1.6 1.21 2.6 3.13 2.6 5.15v1.61h2c2.6 0 4.69 2.12 4.69 4.74-0.099 2.52-2.2 4.64-4.79 4.64z"/>
|
||||
<path d="m53.2 49.2h-41.6c-1.8 0-3.2 1.4-3.2 3.2v28.6c0 1.8 1.4 3.2 3.2 3.2h15.8v4h-7v6h24v-6h-7v-4h15.8c1.8 0 3.2-1.4 3.2-3.2v-28.6c0-1.8-1.4-3.2-3.2-3.2zm-2.8 29h-36v-23h36z"/>
|
||||
<path d="m73 49.2c1.02 1.29 1.53 2.97 1.53 4.56 0 2.97-1.74 5.65-4.39 7.04v-4.06l-7.46 7.33 7.46 7.14v-4.06c7.66-1.98 12.2-9.61 10-17-0.102-0.297-0.205-0.595-0.307-0.892z"/>
|
||||
<path d="m24.1 43c-0.817-0.991-1.53-2.97-1.53-4.56 0-2.97 1.74-5.65 4.39-7.04v4.06l7.46-7.33-7.46-7.14v4.06c-7.66 1.98-12.2 9.61-10 17 0.102 0.297 0.205 0.595 0.307 0.892z"/>
|
||||
</g>`
|
||||
);
|
||||
|
||||
this.addRibbonIcon("replicate", $msg("moduleObsidianMenu.replicate"), async () => {
|
||||
await this.services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
}).addClass("livesync-ribbon-replicate");
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({ addIcon: vi.fn() }));
|
||||
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { ModuleObsidianMenu } from "./ModuleObsidianMenu";
|
||||
|
||||
describe("ModuleObsidianMenu ribbon", () => {
|
||||
it("retains visible progress and full interaction authority", async () => {
|
||||
let runRibbonAction: (() => Promise<void>) | undefined;
|
||||
const addClass = vi.fn();
|
||||
const replicateUserInitiated = vi.fn(async () => ({ status: "completed" as const }));
|
||||
const services = {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
addRibbonIcon: vi.fn((_icon: string, _title: string, callback: () => Promise<void>) => {
|
||||
runRibbonAction = callback;
|
||||
return { addClass };
|
||||
}),
|
||||
},
|
||||
replication: { replicateUserInitiated },
|
||||
};
|
||||
const module = new ModuleObsidianMenu({ _services: services, services } as never);
|
||||
|
||||
await module._everyOnloadStart();
|
||||
await runRibbonAction?.();
|
||||
|
||||
expect(replicateUserInitiated).toHaveBeenCalledWith({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
expect(addClass).toHaveBeenCalledWith("livesync-ribbon-replicate");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user