From 7c43c61b85b8ce655ad082098aa1398eebafee5a Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sat, 24 Jan 2026 16:25:04 +0900 Subject: [PATCH] ### Fixed - No longer `No available splitter for settings!!` errors occur after fetching old remote settings while rebuilding local database. ### Improved - Boot sequence warning is now kept in the in-editor notification area. (#748) ### New feature - We can now set the maximum modified time for reflect events in the settings. (for #754) ### Refactored - Module to service refactoring has been started for better maintainability: - UI module has been moved to UI service. ### Behaviour change - Default chunk splitter version has been changed to `Rabin-Karp` for new installations. --- src/lib | 2 +- src/main.ts | 6 +- src/modules/core/ModuleRebuilder.ts | 26 ++++ src/modules/core/ReplicateResultProcessor.ts | 14 +++ .../coreObsidian/ModuleInputUIObsidian.ts | 118 ------------------ .../storageLib/StorageEventManager.ts | 3 + .../essential/ModuleInitializerFile.ts | 52 +++++--- .../features/SettingDialogue/PanePatches.ts | 55 ++++++++ src/modules/services/ObsidianUIService.ts | 111 +++++++++++++++- 9 files changed, 248 insertions(+), 139 deletions(-) delete mode 100644 src/modules/coreObsidian/ModuleInputUIObsidian.ts diff --git a/src/lib b/src/lib index 5b42808..cd32d3d 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit 5b42808773eae3da5656e253cfbde1eb21ed1f23 +Subproject commit cd32d3d32635a536266efbf7f974b47f70b878c5 diff --git a/src/main.ts b/src/main.ts index 2f80bf9..7be8578 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,7 +23,6 @@ import type { IObsidianModule } from "./modules/AbstractObsidianModule.ts"; import { ModuleDev } from "./modules/extras/ModuleDev.ts"; import { ModuleFileAccessObsidian } from "./modules/coreObsidian/ModuleFileAccessObsidian.ts"; -import { ModuleInputUIObsidian } from "./modules/coreObsidian/ModuleInputUIObsidian.ts"; import { ModuleMigration } from "./modules/essential/ModuleMigration.ts"; import { ModuleCheckRemoteSize } from "./modules/essentialObsidian/ModuleCheckRemoteSize.ts"; @@ -137,7 +136,6 @@ export default class ObsidianLiveSyncPlugin new ModuleObsidianSettingsAsMarkdown(this, this), new ModuleObsidianSettingDialogue(this, this), new ModuleLog(this, this), - new ModuleInputUIObsidian(this, this), new ModuleObsidianMenu(this, this), new ModuleRebuilder(this), new ModuleSetupObsidian(this, this), @@ -166,7 +164,9 @@ export default class ObsidianLiveSyncPlugin managers!: LiveSyncManagers; simpleStore!: SimpleStore; replicator!: LiveSyncAbstractReplicator; - confirm!: Confirm; + get confirm(): Confirm { + return this.services.UI.confirm; + } storageAccess!: StorageAccess; databaseFileAccess!: DatabaseFileAccess; fileHandler!: ModuleFileHandler; diff --git a/src/modules/core/ModuleRebuilder.ts b/src/modules/core/ModuleRebuilder.ts index 0872d95..6e5656b 100644 --- a/src/modules/core/ModuleRebuilder.ts +++ b/src/modules/core/ModuleRebuilder.ts @@ -194,6 +194,32 @@ Please enable them from the settings screen after setup is complete.`, // await this.askUseNewAdapter(); this.core.settings.isConfigured = true; this.core.settings.notifyThresholdOfRemoteStorageSize = DEFAULT_SETTINGS.notifyThresholdOfRemoteStorageSize; + if (this.core.settings.maxMTimeForReflectEvents > 0) { + const date = new Date(this.core.settings.maxMTimeForReflectEvents); + + const ask = `Your settings restrict file reflection times to no later than ${date}. + +**This is a recovery configuration.** + +This operation should only be performed on an empty vault. +Are you sure you wish to proceed?`; + const PROCEED = "I understand, proceed"; + const CANCEL = "Cancel operation"; + const CLEARANDPROCEED = "Clear restriction and proceed"; + const choices = [PROCEED, CLEARANDPROCEED, CANCEL] as const; + const ret = await this.core.confirm.askSelectStringDialogue(ask, choices, { + title: "Confirm restricted fetch", + defaultAction: CANCEL, + timeout: 0, + }); + if (ret == CLEARANDPROCEED) { + this.core.settings.maxMTimeForReflectEvents = 0; + await this.core.saveSettings(); + } + if (ret == CANCEL) { + return; + } + } await this.suspendReflectingDatabase(); await this.services.setting.realiseSetting(); await this.resetLocalDatabase(); diff --git a/src/modules/core/ReplicateResultProcessor.ts b/src/modules/core/ReplicateResultProcessor.ts index fcb45d6..6aa1acd 100644 --- a/src/modules/core/ReplicateResultProcessor.ts +++ b/src/modules/core/ReplicateResultProcessor.ts @@ -318,6 +318,20 @@ export class ReplicateResultProcessor { */ async parseDocumentChange(change: PouchDB.Core.ExistingDocument) { try { + if (isAnyNote(change)) { + const docMtime = change.mtime ?? 0; + const maxMTime = this.replicator.settings.maxMTimeForReflectEvents; + if (maxMTime > 0 && docMtime > maxMTime) { + const docPath = getPath(change); + this.log( + `Processing ${docPath} has been skipped due to modification time (${new Date( + docMtime * 1000 + ).toISOString()}) exceeding the limit`, + LOG_LEVEL_INFO + ); + return; + } + } // If the document is a virtual document, process it in the virtual document processor. if (await this.services.replication.processVirtualDocument(change)) return; // If the document is version info, check compatibility and return. diff --git a/src/modules/coreObsidian/ModuleInputUIObsidian.ts b/src/modules/coreObsidian/ModuleInputUIObsidian.ts deleted file mode 100644 index c209116..0000000 --- a/src/modules/coreObsidian/ModuleInputUIObsidian.ts +++ /dev/null @@ -1,118 +0,0 @@ -// ModuleInputUIObsidian.ts -import { AbstractObsidianModule } from "../AbstractObsidianModule.ts"; -import { scheduleTask } from "octagonal-wheels/concurrency/task"; -import { disposeMemoObject, memoIfNotExist, memoObject, retrieveMemoObject } from "../../common/utils.ts"; -import { - askSelectString, - askString, - askYesNo, - confirmWithMessage, - confirmWithMessageWithWideButton, -} from "./UILib/dialogs.ts"; -import { Notice } from "../../deps.ts"; -import type { Confirm } from "../../lib/src/interfaces/Confirm.ts"; -import { setConfirmInstance } from "../../lib/src/PlatformAPIs/obsidian/Confirm.ts"; -import { $msg } from "src/lib/src/common/i18n.ts"; -import type { LiveSyncCore } from "../../main.ts"; - -// This module cannot be a common module because it depends on Obsidian's API. -// However, we have to make compatible one for other platform. - -export class ModuleInputUIObsidian extends AbstractObsidianModule implements Confirm { - private _everyOnload(): Promise { - this.core.confirm = this; - setConfirmInstance(this); - return Promise.resolve(true); - } - - askYesNo(message: string): Promise<"yes" | "no"> { - return askYesNo(this.app, message); - } - askString(title: string, key: string, placeholder: string, isPassword: boolean = false): Promise { - return askString(this.app, title, key, placeholder, isPassword); - } - - async askYesNoDialog( - message: string, - opt: { title?: string; defaultOption?: "Yes" | "No"; timeout?: number } = { title: "Confirmation" } - ): Promise<"yes" | "no"> { - const defaultTitle = $msg("moduleInputUIObsidian.defaultTitleConfirmation"); - const yesLabel = $msg("moduleInputUIObsidian.optionYes"); - const noLabel = $msg("moduleInputUIObsidian.optionNo"); - const defaultOption = opt.defaultOption === "Yes" ? yesLabel : noLabel; - const ret = await confirmWithMessageWithWideButton( - this.plugin, - opt.title || defaultTitle, - message, - [yesLabel, noLabel], - defaultOption, - opt.timeout - ); - return ret === yesLabel ? "yes" : "no"; - } - - askSelectString(message: string, items: string[]): Promise { - return askSelectString(this.app, message, items); - } - - askSelectStringDialogue( - message: string, - buttons: T, - opt: { title?: string; defaultAction: T[number]; timeout?: number } - ): Promise { - const defaultTitle = $msg("moduleInputUIObsidian.defaultTitleSelect"); - return confirmWithMessageWithWideButton( - this.plugin, - opt.title || defaultTitle, - message, - buttons, - opt.defaultAction, - opt.timeout - ); - } - - askInPopup(key: string, dialogText: string, anchorCallback: (anchor: HTMLAnchorElement) => void) { - const fragment = createFragment((doc) => { - const [beforeText, afterText] = dialogText.split("{HERE}", 2); - doc.createEl("span", undefined, (a) => { - a.appendText(beforeText); - a.appendChild( - a.createEl("a", undefined, (anchor) => { - anchorCallback(anchor); - }) - ); - a.appendText(afterText); - }); - }); - const popupKey = "popup-" + key; - scheduleTask(popupKey, 1000, async () => { - const popup = await memoIfNotExist(popupKey, () => new Notice(fragment, 0)); - const isShown = popup?.noticeEl?.isShown(); - if (!isShown) { - memoObject(popupKey, new Notice(fragment, 0)); - } - scheduleTask(popupKey + "-close", 20000, () => { - const popup = retrieveMemoObject(popupKey); - if (!popup) return; - if (popup?.noticeEl?.isShown()) { - popup.hide(); - } - disposeMemoObject(popupKey); - }); - }); - } - - confirmWithMessage( - title: string, - contentMd: string, - buttons: string[], - defaultAction: (typeof buttons)[number], - timeout?: number - ): Promise<(typeof buttons)[number] | false> { - return confirmWithMessage(this.plugin, title, contentMd, buttons, defaultAction, timeout); - } - - onBindFunction(core: LiveSyncCore, services: typeof core.services): void { - services.appLifecycle.onLoaded.addHandler(this._everyOnload.bind(this)); - } -} diff --git a/src/modules/coreObsidian/storageLib/StorageEventManager.ts b/src/modules/coreObsidian/storageLib/StorageEventManager.ts index d1dac58..04fcefb 100644 --- a/src/modules/coreObsidian/storageLib/StorageEventManager.ts +++ b/src/modules/coreObsidian/storageLib/StorageEventManager.ts @@ -243,6 +243,9 @@ export class StorageEventManagerObsidian extends StorageEventManager { async appendQueue(params: FileEvent[], ctx?: any) { if (!this.core.settings.isConfigured) return; if (this.core.settings.suspendFileWatching) return; + if (this.core.settings.maxMTimeForReflectEvents > 0) { + return; + } this.core.services.vault.markFileListPossiblyChanged(); // Flag up to be reload for (const param of params) { diff --git a/src/modules/essential/ModuleInitializerFile.ts b/src/modules/essential/ModuleInitializerFile.ts index b9dfb7e..78e3c4b 100644 --- a/src/modules/essential/ModuleInitializerFile.ts +++ b/src/modules/essential/ModuleInitializerFile.ts @@ -1,6 +1,6 @@ import { unique } from "octagonal-wheels/collection"; import { throttle } from "octagonal-wheels/function"; -import { eventHub } from "../../common/events.ts"; +import { EVENT_ON_UNRESOLVED_ERROR, eventHub } from "../../common/events.ts"; import { BASE_IS_NEW, compareFileFreshness, EVEN, getPath, isValidPath, TARGET_IS_NEW } from "../../common/utils.ts"; import { type FilePathWithPrefixLC, @@ -13,6 +13,7 @@ import { LOG_LEVEL_INFO, LOG_LEVEL_DEBUG, type UXFileInfoStub, + type LOG_LEVEL, } from "../../lib/src/common/types.ts"; import { isAnyNote } from "../../lib/src/common/utils.ts"; import { stripAllPrefixes } from "../../lib/src/string_and_binary/path.ts"; @@ -21,30 +22,43 @@ import { withConcurrency } from "octagonal-wheels/iterable/map"; import type { InjectableServiceHub } from "../../lib/src/services/InjectableServices.ts"; import type { LiveSyncCore } from "../../main.ts"; export class ModuleInitializerFile extends AbstractModule { + private _detectedErrors = new Set(); + + private logDetectedError(message: string, logLevel: LOG_LEVEL = LOG_LEVEL_INFO, key?: string) { + this._detectedErrors.add(message); + eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR); + this._log(message, logLevel, key); + } + private resetDetectedError(message: string) { + eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR); + this._detectedErrors.delete(message); + } private async _performFullScan(showingNotice?: boolean, ignoreSuspending: boolean = false): Promise { this._log("Opening the key-value database", LOG_LEVEL_VERBOSE); const isInitialized = (await this.core.kvDB.get("initialized")) || false; // synchronize all files between database and storage. + + const ERR_NOT_CONFIGURED = + "LiveSync is not configured yet. Synchronising between the storage and the local database is now prevented."; if (!this.settings.isConfigured) { - if (showingNotice) { - this._log( - "LiveSync is not configured yet. Synchronising between the storage and the local database is now prevented.", - LOG_LEVEL_NOTICE, - "syncAll" - ); - } + this.logDetectedError(ERR_NOT_CONFIGURED, showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO, "syncAll"); return false; } + this.resetDetectedError(ERR_NOT_CONFIGURED); + + const ERR_SUSPENDING = + "Now suspending file watching. Synchronising between the storage and the local database is now prevented."; if (!ignoreSuspending && this.settings.suspendFileWatching) { - if (showingNotice) { - this._log( - "Now suspending file watching. Synchronising between the storage and the local database is now prevented.", - LOG_LEVEL_NOTICE, - "syncAll" - ); - } + this.logDetectedError(ERR_SUSPENDING, showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO, "syncAll"); return false; } + const MSG_IN_REMEDIATION = `Started in remediation Mode! (Max mtime for reflect events is set). Synchronising between the storage and the local database is now prevented.`; + this.resetDetectedError(ERR_SUSPENDING); + if (this.settings.maxMTimeForReflectEvents > 0) { + this.logDetectedError(MSG_IN_REMEDIATION, LOG_LEVEL_NOTICE, "syncAll"); + return false; + } + this.resetDetectedError(MSG_IN_REMEDIATION); if (showingNotice) { this._log("Initializing", LOG_LEVEL_NOTICE, "syncAll"); @@ -383,10 +397,12 @@ export class ModuleInitializerFile extends AbstractModule { if (this.localDatabase.isReady) { await this.services.vault.scanVault(showingNotice, ignoreSuspending); } + const ERR_INITIALISATION_FAILED = `Initializing database has been failed on some module!`; if (!(await this.services.databaseEvents.onDatabaseInitialised(showingNotice))) { - this._log(`Initializing database has been failed on some module!`, LOG_LEVEL_NOTICE); + this.logDetectedError(ERR_INITIALISATION_FAILED, LOG_LEVEL_NOTICE); return false; } + this.resetDetectedError(ERR_INITIALISATION_FAILED); this.services.appLifecycle.markIsReady(); // run queued event once. await this.services.fileProcessing.commitPendingFileEvents(); @@ -396,7 +412,11 @@ export class ModuleInitializerFile extends AbstractModule { return false; } } + private _reportDetectedErrors(): Promise { + return Promise.resolve(Array.from(this._detectedErrors)); + } onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void { + services.appLifecycle.getUnresolvedMessages.addHandler(this._reportDetectedErrors.bind(this)); services.databaseEvents.initialiseDatabase.setHandler(this._initializeDatabase.bind(this)); services.vault.scanVault.setHandler(this._performFullScan.bind(this)); } diff --git a/src/modules/features/SettingDialogue/PanePatches.ts b/src/modules/features/SettingDialogue/PanePatches.ts index 096749a..906127e 100644 --- a/src/modules/features/SettingDialogue/PanePatches.ts +++ b/src/modules/features/SettingDialogue/PanePatches.ts @@ -173,7 +173,62 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen void addPanel(paneEl, "Compatibility (Trouble addressed)").then((paneEl) => { new Setting(paneEl).autoWireToggle("disableCheckingConfigMismatch"); }); + void addPanel(paneEl, "Remediation").then((paneEl) => { + let dateEl: HTMLSpanElement; + new Setting(paneEl) + .addText((text) => { + const updateDateText = () => { + if (this.editingSettings.maxMTimeForReflectEvents == 0) { + dateEl.textContent = `No limit configured`; + } else { + const date = new Date(this.editingSettings.maxMTimeForReflectEvents); + dateEl.textContent = `Limit: ${date.toLocaleString()} (${this.editingSettings.maxMTimeForReflectEvents})`; + } + this.requestUpdate(); + }; + text.inputEl.before((dateEl = document.createElement("span"))); + text.inputEl.type = "datetime-local"; + if (this.editingSettings.maxMTimeForReflectEvents > 0) { + const date = new Date(this.editingSettings.maxMTimeForReflectEvents); + const isoString = date.toISOString().slice(0, 16); + text.setValue(isoString); + } else { + text.setValue(""); + } + text.onChange((value) => { + if (value == "") { + this.editingSettings.maxMTimeForReflectEvents = 0; + updateDateText(); + return; + } + const date = new Date(value); + if (!isNaN(date.getTime())) { + this.editingSettings.maxMTimeForReflectEvents = date.getTime(); + } + updateDateText(); + }); + updateDateText(); + return text; + }) + .setAuto("maxMTimeForReflectEvents") + .addApplyButton(["maxMTimeForReflectEvents"]); + this.addOnSaved("maxMTimeForReflectEvents", async (key) => { + const buttons = ["Restart Now", "Later"] as const; + const reboot = await this.plugin.confirm.askSelectStringDialogue( + "Restarting Obsidian is strongly recommended. Until restart, some changes may not take effect, and display may be inconsistent. Are you sure to restart now?", + buttons, + { + title: "Remediation Setting Changed", + defaultAction: "Restart Now", + } + ); + if (reboot !== "Later") { + Logger("Remediation setting changed. Restarting Obsidian...", LOG_LEVEL_NOTICE); + this.services.appLifecycle.performRestart(); + } + }); + }); void addPanel(paneEl, "Remote Database Tweak (In sunset)").then((paneEl) => { // new Setting(paneEl).autoWireToggle("useEden").setClass("wizardHidden"); // const onlyUsingEden = visibleOnly(() => this.isConfiguredAs("useEden", true)); diff --git a/src/modules/services/ObsidianUIService.ts b/src/modules/services/ObsidianUIService.ts index 21bbf90..e4df15e 100644 --- a/src/modules/services/ObsidianUIService.ts +++ b/src/modules/services/ObsidianUIService.ts @@ -1,14 +1,118 @@ import { UIService } from "../../lib/src/services/Services"; -import type { Plugin } from "@/deps"; +import { Notice, type App, type Plugin } from "@/deps"; import { SvelteDialogManager } from "../features/SetupWizard/ObsidianSvelteDialog"; import DialogueToCopy from "../../lib/src/UI/dialogues/DialogueToCopy.svelte"; import type { ObsidianServiceContext } from "./ObsidianServices"; import type ObsidianLiveSyncPlugin from "@/main"; +import type { Confirm } from "@/lib/src/interfaces/Confirm"; +import { + askSelectString, + askString, + askYesNo, + confirmWithMessage, + confirmWithMessageWithWideButton, +} from "../coreObsidian/UILib/dialogs"; +import { $msg } from "@/lib/src/common/i18n"; +import { disposeMemoObject, memoIfNotExist, memoObject, retrieveMemoObject, scheduleTask } from "@/common/utils"; +export class ObsidianConfirm implements Confirm { + private _app: App; + private _plugin: Plugin; + constructor(app: App, plugin: Plugin) { + this._app = app; + this._plugin = plugin; + } + askYesNo(message: string): Promise<"yes" | "no"> { + return askYesNo(this._app, message); + } + askString(title: string, key: string, placeholder: string, isPassword: boolean = false): Promise { + return askString(this._app, title, key, placeholder, isPassword); + } + async askYesNoDialog( + message: string, + opt: { title?: string; defaultOption?: "Yes" | "No"; timeout?: number } = { title: "Confirmation" } + ): Promise<"yes" | "no"> { + const defaultTitle = $msg("moduleInputUIObsidian.defaultTitleConfirmation"); + const yesLabel = $msg("moduleInputUIObsidian.optionYes"); + const noLabel = $msg("moduleInputUIObsidian.optionNo"); + const defaultOption = opt.defaultOption === "Yes" ? yesLabel : noLabel; + const ret = await confirmWithMessageWithWideButton( + this._plugin, + opt.title || defaultTitle, + message, + [yesLabel, noLabel], + defaultOption, + opt.timeout + ); + return ret === yesLabel ? "yes" : "no"; + } + + askSelectString(message: string, items: string[]): Promise { + return askSelectString(this._app, message, items); + } + + askSelectStringDialogue( + message: string, + buttons: T, + opt: { title?: string; defaultAction: T[number]; timeout?: number } + ): Promise { + const defaultTitle = $msg("moduleInputUIObsidian.defaultTitleSelect"); + return confirmWithMessageWithWideButton( + this._plugin, + opt.title || defaultTitle, + message, + buttons, + opt.defaultAction, + opt.timeout + ); + } + + askInPopup(key: string, dialogText: string, anchorCallback: (anchor: HTMLAnchorElement) => void) { + const fragment = createFragment((doc) => { + const [beforeText, afterText] = dialogText.split("{HERE}", 2); + doc.createEl("span", undefined, (a) => { + a.appendText(beforeText); + a.appendChild( + a.createEl("a", undefined, (anchor) => { + anchorCallback(anchor); + }) + ); + a.appendText(afterText); + }); + }); + const popupKey = "popup-" + key; + scheduleTask(popupKey, 1000, async () => { + const popup = await memoIfNotExist(popupKey, () => new Notice(fragment, 0)); + const isShown = popup?.noticeEl?.isShown(); + if (!isShown) { + memoObject(popupKey, new Notice(fragment, 0)); + } + scheduleTask(popupKey + "-close", 20000, () => { + const popup = retrieveMemoObject(popupKey); + if (!popup) return; + if (popup?.noticeEl?.isShown()) { + popup.hide(); + } + disposeMemoObject(popupKey); + }); + }); + } + + confirmWithMessage( + title: string, + contentMd: string, + buttons: string[], + defaultAction: (typeof buttons)[number], + timeout?: number + ): Promise<(typeof buttons)[number] | false> { + return confirmWithMessage(this._plugin, title, contentMd, buttons, defaultAction, timeout); + } +} export class ObsidianUIService extends UIService { private _dialogManager: SvelteDialogManager; private _plugin: Plugin; private _liveSyncPlugin: ObsidianLiveSyncPlugin; + private _confirmInstance: ObsidianConfirm; get dialogManager() { return this._dialogManager; } @@ -17,6 +121,7 @@ export class ObsidianUIService extends UIService { this._liveSyncPlugin = context.liveSyncPlugin; this._dialogManager = new SvelteDialogManager(this._liveSyncPlugin); this._plugin = context.plugin; + this._confirmInstance = new ObsidianConfirm(this._plugin.app, this._plugin); } async promptCopyToClipboard(title: string, value: string): Promise { @@ -44,4 +149,8 @@ export class ObsidianUIService extends UIService { timeout: 0, }); } + + get confirm(): Confirm { + return this._confirmInstance; + } }