From e8c33a0d6a6dc234bfd231fb75f504d90804cf61 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 18 May 2026 11:21:53 +0100 Subject: [PATCH] feat: implement auto-accept compatible tweak setting and enhance mismatch resolution logic --- src/lib | 2 +- .../ModuleResolveMismatchedTweaks.ts | 144 ++++++++++++++++-- ...ModuleResolveMismatchedTweaks.unit.spec.ts | 108 +++++++++++++ .../features/SettingDialogue/PaneAdvanced.ts | 1 + updates.md | 8 + 5 files changed, 246 insertions(+), 17 deletions(-) create mode 100644 src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts diff --git a/src/lib b/src/lib index a0af792..37c3d78 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit a0af792b48e6e7a5b14d7ee932b81796b65bd497 +Subproject commit 37c3d78c6ccb5ee7f2bdf8c02cb7845675501b57 diff --git a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts index d5776ef..2d392c7 100644 --- a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts +++ b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts @@ -2,9 +2,11 @@ import { Logger, LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger"; import { extractObject } from "octagonal-wheels/object"; import { TweakValuesShouldMatchedTemplate, + TweakValuesTemplate, IncompatibleChanges, confName, type TweakValues, + type ObsidianLiveSyncSettings, type RemoteDBSettings, IncompatibleChangesInSpecificPattern, CompatibleButLossyChanges, @@ -16,7 +18,105 @@ import type { InjectableServiceHub } from "../../lib/src/services/InjectableServ import type { LiveSyncCore } from "../../main.ts"; import { REMOTE_P2P } from "@lib/common/models/setting.const.ts"; +function valueToString(value: any) { + if (typeof value === "boolean") { + return value ? "true" : "false"; + } + if (typeof value === "object") { + return JSON.stringify(value); + } + return `${value}`; +} + export class ModuleResolvingMismatchedTweaks extends AbstractModule { + private _hasNotifiedAutoAcceptCompatibleUndefined = false; + + private _collectMismatchedTweakKeys(current: TweakValues, preferred: Partial) { + const items = Object.keys( + TweakValuesShouldMatchedTemplate + ) as (keyof typeof TweakValuesShouldMatchedTemplate)[]; + return items.filter((key) => current[key] !== preferred[key]); + } + + private _selectNewerTweakSide(current: TweakValues, preferred: Partial): "REMOTE" | "CURRENT" { + Logger(`Modified: ${current.tweakModified} (current) vs ${preferred.tweakModified} (preferred)`); + const currentModified = current.tweakModified; + const preferredModified = preferred.tweakModified; + // debugger; + const hasCurrentModified = typeof currentModified === "number" && currentModified > 0; + const hasPreferredModified = typeof preferredModified === "number" && preferredModified > 0; + + if (!hasCurrentModified && !hasPreferredModified) return "REMOTE"; + if (!hasCurrentModified) return "REMOTE"; + if (!hasPreferredModified) return "CURRENT"; + if (preferredModified >= currentModified) return "REMOTE"; + return "CURRENT"; + } + + private async _shouldAutoAcceptCompatibleLossy( + current: TweakValues, + preferred: Partial, + mismatchedKeys: (keyof typeof TweakValuesShouldMatchedTemplate)[] + ): Promise<"REMOTE" | "CURRENT" | undefined> { + if (mismatchedKeys.length === 0) return undefined; + const hasOnlyCompatibleLossyMismatches = mismatchedKeys.every( + (key) => CompatibleButLossyChanges.indexOf(key) !== -1 + ); + if (!hasOnlyCompatibleLossyMismatches) return undefined; + + if (this.settings.autoAcceptCompatibleTweak === undefined) { + if (this._hasNotifiedAutoAcceptCompatibleUndefined) { + return undefined; + } + this._hasNotifiedAutoAcceptCompatibleUndefined = true; + const CHOICE_ENABLE = $msg("TweakMismatchResolve.Action.EnableAutoAcceptCompatible"); + const CHOICE_DISABLE = $msg("TweakMismatchResolve.Action.DisableAutoAcceptCompatible"); + const CHOICES = [CHOICE_ENABLE, CHOICE_DISABLE] as const; + const message = $msg("TweakMismatchResolve.Message.AutoAcceptCompatibleUndefined"); + const ret = await this.core.confirm.askSelectStringDialogue(message, CHOICES, { + title: $msg("TweakMismatchResolve.Title.AutoAcceptCompatible"), + timeout: 0, + defaultAction: CHOICE_ENABLE, + }); + if (ret !== CHOICE_ENABLE) { + return undefined; + } + await this.services.setting.applyPartial( + { + autoAcceptCompatibleTweak: true, + }, + true + ); + Logger("Auto-accept for compatible tweak mismatch has been enabled."); + } + + if (this.settings.autoAcceptCompatibleTweak !== true) return undefined; + return this._selectNewerTweakSide(current, preferred); + } + + /** + * Hook before saving settings, to check if there are changes in tweak values, and if so, + * update the tweakModified timestamp to current time. + * This allows other devices to know that the tweak values have been changed and decide whether to accept the new values based on the modification time. + * @param next + * @param previous + * @returns + */ + async _onBeforeSaveSettingData(next: ObsidianLiveSyncSettings, previous: ObsidianLiveSyncSettings) { + const tweakKeys = Object.keys(TweakValuesTemplate) as (keyof TweakValues)[]; + const tweakKeysForUpdate = tweakKeys.filter((key) => key !== "tweakModified"); + const hasChangedTweak = tweakKeysForUpdate.some((key) => next[key] !== previous[key]); + if (!hasChangedTweak) return; + Logger( + `Some tweak values have been changed. ${tweakKeysForUpdate.filter((key) => next[key] !== previous[key]).join(", ")}` + ); + const modified = Date.now(); + Logger(`Modified: ${modified}`); + return await Promise.resolve({ + tweakModified: modified, + }); + } + async _anyAfterConnectCheckFailed(): Promise { if (!this.core.replicator.tweakSettingsMismatched && !this.core.replicator.preferredTweakValue) return false; const preferred = this.core.replicator.preferredTweakValue; @@ -27,10 +127,16 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { if (ret == "IGNORE") return true; } - async _checkAndAskResolvingMismatchedTweaks( - preferred: Partial - ): Promise<[TweakValues | boolean, boolean]> { - const mine = extractObject(TweakValuesShouldMatchedTemplate, this.settings); + async _checkAndAskResolvingMismatchedTweaks(preferred: TweakValues): Promise<[TweakValues | boolean, boolean]> { + const mine = extractObject(TweakValuesShouldMatchedTemplate, this.settings) as TweakValues; + const mismatchedKeys = this._collectMismatchedTweakKeys(mine, preferred); + const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(mine, preferred, mismatchedKeys); + if (autoAcceptSide === "REMOTE") { + return [{ ...mine, ...preferred }, false]; + } + if (autoAcceptSide === "CURRENT") { + return [true, false]; + } const items = Object.entries(TweakValuesShouldMatchedTemplate); let rebuildRequired = false; let rebuildRecommended = false; @@ -69,8 +175,8 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { tableRows.push( $msg("TweakMismatchResolve.Table.Row", { name: confName(key), - self: valueMine, - remote: valuePreferred, + self: valueToString(valueMine), + remote: valueToString(valuePreferred), }) ); } @@ -137,9 +243,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { if (!tweaks) { return "IGNORE"; } - const preferred = extractObject(TweakValuesShouldMatchedTemplate, tweaks); - - const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(preferred); + const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(tweaks); if (!conf) return "IGNORE"; if (conf === true) { @@ -147,10 +251,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { if (rebuildRequired) { await this.core.rebuilder.$rebuildRemote(); } - Logger( - `Tweak values on the remote server have been updated. Your other device will see this message.`, - LOG_LEVEL_NOTICE - ); + Logger($msg("TweakMismatchResolve.Message.remoteUpdated"), LOG_LEVEL_NOTICE); return "CHECKAGAIN"; } if (conf) { @@ -160,7 +261,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { if (rebuildRequired) { await this.core.rebuilder.$fetchLocal(); } - Logger(`Configuration has been updated as configured by the other device.`, LOG_LEVEL_NOTICE); + Logger($msg("TweakMismatchResolve.Message.mineUpdated"), LOG_LEVEL_NOTICE); return "CHECKAGAIN"; } return "IGNORE"; @@ -201,6 +302,16 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { trialSetting: RemoteDBSettings, preferred: TweakValues ): Promise<{ result: false | TweakValues; requireFetch: boolean }> { + const localTweaks = extractObject(TweakValuesTemplate, this.settings) as TweakValues; + const mismatchedKeys = this._collectMismatchedTweakKeys(localTweaks, preferred); + const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(localTweaks, preferred, mismatchedKeys); + if (autoAcceptSide === "REMOTE") { + return { result: { ...trialSetting, ...preferred }, requireFetch: false }; + } + if (autoAcceptSide === "CURRENT") { + return { result: false, requireFetch: false }; + } + const items = Object.entries(TweakValuesShouldMatchedTemplate); let rebuildRequired = false; let rebuildRecommended = false; @@ -211,8 +322,8 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { // const items = [mine,preferred] for (const v of items) { const key = v[0] as keyof typeof TweakValuesShouldMatchedTemplate; - const remoteValueForDisplay = escapeMarkdownValue(preferred[key]); - const currentValueForDisplay = `${escapeMarkdownValue((trialSetting as TweakValues)?.[key])}`; + const remoteValueForDisplay = escapeMarkdownValue(valueToString(preferred[key])); + const currentValueForDisplay = escapeMarkdownValue(valueToString((trialSetting as TweakValues)?.[key])); if ((trialSetting as TweakValues)?.[key] !== preferred[key]) { if (IncompatibleChanges.indexOf(key) !== -1) { rebuildRequired = true; @@ -289,6 +400,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { } override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void { + services.setting.onBeforeSaveSettingData.addHandler(this._onBeforeSaveSettingData.bind(this)); services.tweakValue.fetchRemotePreferred.setHandler(this._fetchRemotePreferredTweakValues.bind(this)); services.tweakValue.checkAndAskResolvingMismatched.setHandler( this._checkAndAskResolvingMismatchedTweaks.bind(this) diff --git a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts new file mode 100644 index 0000000..4415891 --- /dev/null +++ b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from "vitest"; +import { DEFAULT_SETTINGS, REMOTE_COUCHDB, type RemoteDBSettings, type TweakValues } from "@lib/common/types"; +import { ModuleResolvingMismatchedTweaks } from "./ModuleResolveMismatchedTweaks"; + +function createModule(settingsOverride: Partial = {}) { + const askSelectStringDialogue = vi.fn(async () => undefined); + const core = { + _services: { + API: { + addLog: vi.fn(), + addCommand: vi.fn(), + registerWindow: vi.fn(), + addRibbonIcon: vi.fn(), + registerProtocolHandler: vi.fn(), + }, + setting: { + saveSettingData: vi.fn(async () => undefined), + }, + }, + settings: { + ...DEFAULT_SETTINGS, + remoteType: REMOTE_COUCHDB, + ...settingsOverride, + }, + confirm: { + askSelectStringDialogue, + }, + } as any; + + Object.defineProperty(core, "services", { + get() { + return core._services; + }, + }); + + const module = new ModuleResolvingMismatchedTweaks(core); + return { module, core, askSelectStringDialogue }; +} + +describe("ModuleResolvingMismatchedTweaks", () => { + it("should auto-accept compatible mismatches on connect check using newer remote tweakModified", async () => { + const { module, askSelectStringDialogue } = createModule({ + autoAcceptCompatibleTweak: true, + hashAlg: "xxhash64", + tweakModified: 100, + }); + + const preferred = { + ...(DEFAULT_SETTINGS as unknown as TweakValues), + hashAlg: "xxhash32", + tweakModified: 200, + } as Partial; + + const [conf, rebuild] = await module._checkAndAskResolvingMismatchedTweaks(preferred); + + expect(conf).toEqual(preferred); + expect(rebuild).toBe(false); + expect(askSelectStringDialogue).not.toHaveBeenCalled(); + }); + + it("should fallback to manual confirmation when mismatches are mixed on connect check", async () => { + const { module, askSelectStringDialogue } = createModule({ + autoAcceptCompatibleTweak: true, + hashAlg: "xxhash64", + encrypt: false, + tweakModified: 100, + }); + + const preferred = { + ...(DEFAULT_SETTINGS as unknown as TweakValues), + hashAlg: "xxhash32", + encrypt: true, + tweakModified: 200, + } as Partial; + + const [conf, rebuild] = await module._checkAndAskResolvingMismatchedTweaks(preferred); + + expect(conf).toBe(false); + expect(rebuild).toBe(false); + expect(askSelectStringDialogue).toHaveBeenCalledTimes(1); + }); + + it("should auto-accept compatible mismatches on remote-config check using newer local tweakModified", async () => { + const { module, askSelectStringDialogue } = createModule({ + autoAcceptCompatibleTweak: true, + hashAlg: "xxhash64", + tweakModified: 300, + }); + + const trialSetting = { + ...DEFAULT_SETTINGS, + remoteType: REMOTE_COUCHDB, + hashAlg: "xxhash64", + tweakModified: 300, + } as RemoteDBSettings; + + const preferred = { + ...(trialSetting as unknown as TweakValues), + hashAlg: "xxhash32", + tweakModified: 200, + } as TweakValues; + + const result = await module._askUseRemoteConfiguration(trialSetting, preferred); + + expect(result).toEqual({ result: false, requireFetch: false }); + expect(askSelectStringDialogue).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/features/SettingDialogue/PaneAdvanced.ts b/src/modules/features/SettingDialogue/PaneAdvanced.ts index 2ac6cf0..34c58e8 100644 --- a/src/modules/features/SettingDialogue/PaneAdvanced.ts +++ b/src/modules/features/SettingDialogue/PaneAdvanced.ts @@ -35,6 +35,7 @@ export function paneAdvanced(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme clampMin: 10, onUpdate: this.onlyOnCouchDB, }); + new Setting(paneEl).setClass("wizardHidden").autoWireToggle("autoAcceptCompatibleTweak"); // new Setting(paneEl) // .setClass("wizardHidden") // .autoWireToggle("sendChunksBulk", { onUpdate: onlyOnCouchDB }) diff --git a/updates.md b/updates.md index 8a4e9e6..4eedc41 100644 --- a/updates.md +++ b/updates.md @@ -3,6 +3,14 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025) The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope. +## Unreleased + +### New features +- Implement auto-accept compatible tweak setting and enhance mismatch resolution logic. + +### Improved +- Many messages related to tweak mismatch resolution have been updated for clarity. + ## 0.25.64 17th May, 2026