From c4f2baef5e43699c8d6e17b7087123c6bf5bc7bc Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Thu, 6 Nov 2025 09:24:16 +0000 Subject: [PATCH] ### Fixed #### JWT Authentication - Now we can use JWT Authentication ES512 correctly (#742). - Several misdirections in the Setting dialogues have been fixed (i.e., seconds and minutes confusion...). - The key area in the Setting dialogue has been enlarged and accepts newlines correctly. - Caching of JWT tokens now works correctly - Tokens are now cached and reused until they expire. - They will be kept until 10% of the expiration duration is remaining or 10 seconds, whichever is longer (but at a maximum of 1 minute). - JWT settings are now correctly displayed on the Setting dialogue. #### Other fixes - Receiving non-latest revisions no longer causes unexpected overwrites. - On receiving revisions that made conflicting changes, we are still able to handle them. ### Improved - No longer duplicated message notifications are shown when a connection to the remote server fails. - Instead, a single notification is shown, and it will be kept on the notification area inside the editor until the situation is resolved. - The notification area is no longer imposing, distracting, and overwhelming. - With a pale background, but bordered and with icons. --- src/common/events.ts | 2 + src/lib | 2 +- src/modules/core/ModuleReplicator.ts | 94 +++++++++++++++++-- .../essentialObsidian/ModuleObsidianAPI.ts | 35 ++++++- src/modules/features/ModuleLog.ts | 22 ++++- .../features/SettingDialogue/settingUtils.ts | 2 +- .../dialogs/SetupRemoteCouchDB.svelte | 14 +-- styles.css | 11 ++- 8 files changed, 156 insertions(+), 26 deletions(-) diff --git a/src/common/events.ts b/src/common/events.ts index 3f4ed9c..6bd1172 100644 --- a/src/common/events.ts +++ b/src/common/events.ts @@ -21,6 +21,7 @@ export const EVENT_REQUEST_CLOSE_P2P = "request-close-p2p"; export const EVENT_REQUEST_RUN_DOCTOR = "request-run-doctor"; export const EVENT_REQUEST_RUN_FIX_INCOMPLETE = "request-run-fix-incomplete"; +export const EVENT_ON_UNRESOLVED_ERROR = "on-unresolved-error"; // export const EVENT_FILE_CHANGED = "file-changed"; @@ -40,6 +41,7 @@ declare global { [EVENT_REQUEST_SHOW_SETUP_QR]: undefined; [EVENT_REQUEST_RUN_DOCTOR]: string; [EVENT_REQUEST_RUN_FIX_INCOMPLETE]: undefined; + [EVENT_ON_UNRESOLVED_ERROR]: undefined; } } diff --git a/src/lib b/src/lib index 08b43da..e7197a3 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit 08b43da7fb564faf3bc0e6640019ff6080fa06d7 +Subproject commit e7197a38d8a84ce631fdd493a4a4a656a2aa4872 diff --git a/src/modules/core/ModuleReplicator.ts b/src/modules/core/ModuleReplicator.ts index 6340d99..438b505 100644 --- a/src/modules/core/ModuleReplicator.ts +++ b/src/modules/core/ModuleReplicator.ts @@ -1,7 +1,15 @@ import { fireAndForget, yieldMicrotask } from "octagonal-wheels/promises"; import type { LiveSyncLocalDB } from "../../lib/src/pouchdb/LiveSyncLocalDB"; import { AbstractModule } from "../AbstractModule"; -import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger"; +import { + Logger, + LOG_LEVEL_NOTICE, + LOG_LEVEL_INFO, + LOG_LEVEL_VERBOSE, + LEVEL_NOTICE, + LEVEL_INFO, + type LOG_LEVEL, +} from "octagonal-wheels/common/logger"; import { isLockAcquired, shareRunningResult, skipIfDuplicated } from "octagonal-wheels/concurrency/lock"; import { balanceChunkPurgedDBs } from "@/lib/src/pouchdb/chunks"; import { purgeUnreferencedChunks } from "@/lib/src/pouchdb/chunks"; @@ -28,7 +36,7 @@ import { updatePreviousExecutionTime, } from "../../common/utils"; import { isAnyNote } from "../../lib/src/common/utils"; -import { EVENT_FILE_SAVED, EVENT_SETTING_SAVED, eventHub } from "../../common/events"; +import { EVENT_FILE_SAVED, EVENT_ON_UNRESOLVED_ERROR, EVENT_SETTING_SAVED, eventHub } from "../../common/events"; import type { LiveSyncAbstractReplicator } from "../../lib/src/replication/LiveSyncAbstractReplicator"; import { $msg } from "../../lib/src/common/i18n"; @@ -40,6 +48,20 @@ const REPLICATION_ON_EVENT_FORECASTED_TIME = 5000; export class ModuleReplicator extends AbstractModule { _replicatorType?: RemoteType; + _previousErrors = new Set(); + + showError(msg: string, max_log_level: LOG_LEVEL = LEVEL_NOTICE) { + const level = this._previousErrors.has(msg) ? LEVEL_INFO : max_log_level; + this._log(msg, level); + if (!this._previousErrors.has(msg)) { + this._previousErrors.add(msg); + eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR); + } + } + clearErrors() { + this._previousErrors.clear(); + eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR); + } private _everyOnloadAfterLoadSettings(): Promise { eventHub.onEvent(EVENT_FILE_SAVED, () => { @@ -59,7 +81,7 @@ export class ModuleReplicator extends AbstractModule { async setReplicator() { const replicator = await this.services.replicator.getNewReplicator(); if (!replicator) { - this._log($msg("Replicator.Message.InitialiseFatalError"), LOG_LEVEL_NOTICE); + this.showError($msg("Replicator.Message.InitialiseFatalError"), LOG_LEVEL_NOTICE); return false; } if (this.core.replicator) { @@ -89,7 +111,7 @@ export class ModuleReplicator extends AbstractModule { // Checking salt const replicator = this.services.replicator.getActiveReplicator(); if (!replicator) { - this._log($msg("Replicator.Message.InitialiseFatalError"), LOG_LEVEL_NOTICE); + this.showError($msg("Replicator.Message.InitialiseFatalError"), LOG_LEVEL_NOTICE); return false; } return await replicator.ensurePBKDF2Salt(this.settings, showMessage, true); @@ -98,15 +120,16 @@ export class ModuleReplicator extends AbstractModule { async _everyBeforeReplicate(showMessage: boolean): Promise { // Checking salt if (!this.core.managers.networkManager.isOnline) { - this._log("Network is offline", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO); + this.showError("Network is offline", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO); return false; } // Showing message is false: that because be shown here. (And it is a fatal error, no way to hide it). if (!(await this.ensureReplicatorPBKDF2Salt(false))) { - Logger("Failed to initialise the encryption key, preventing replication.", LOG_LEVEL_NOTICE); + this.showError("Failed to initialise the encryption key, preventing replication."); return false; } await this.loadQueuedFiles(); + this.clearErrors(); return true; } @@ -195,18 +218,19 @@ Even if you choose to clean up, you will see this option again if you exit Obsid } if (!(await this.services.fileProcessing.commitPendingFileEvents())) { - Logger($msg("Replicator.Message.Pending"), LOG_LEVEL_NOTICE); + this.showError($msg("Replicator.Message.Pending"), LOG_LEVEL_NOTICE); return false; } if (!this.core.managers.networkManager.isOnline) { - this._log("Network is offline", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO); + this.showError("Network is offline", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO); return false; } if (!(await this.services.replication.onBeforeReplicate(showMessage))) { - Logger($msg("Replicator.Message.SomeModuleFailed"), LOG_LEVEL_NOTICE); + this.showError($msg("Replicator.Message.SomeModuleFailed"), LOG_LEVEL_NOTICE); return false; } + this.clearErrors(); return true; } @@ -401,11 +425,56 @@ Even if you choose to clean up, you will see this option again if you exit Obsid this.saveQueuedFiles(); }); + async checkIsChangeRequiredForDatabaseProcessing(dbDoc: LoadedEntry): Promise { + const path = getPath(dbDoc); + try { + const savedDoc = await this.localDatabase.getRaw(dbDoc._id, { + conflicts: true, + revs_info: true, + }); + const newRev = dbDoc._rev ?? ""; + const latestRev = savedDoc._rev ?? ""; + const revisions = savedDoc._revs_info?.map((e) => e.rev) ?? []; + if (savedDoc._conflicts && savedDoc._conflicts.length > 0) { + // There are conflicts, so we have to process it. + return true; + } + if (newRev == latestRev) { + // The latest revision. We need to process it. + return true; + } + const index = revisions.indexOf(newRev); + if (index >= 0) { + // the revision has been inserted before. + return false; // Already processed. + } + return true; // This mostly should not happen, but we have to process it just in case. + } catch (e: any) { + if ("status" in e && e.status == 404) { + return true; + // Not existing, so we have to process it. + } else { + Logger( + `Failed to get existing document for ${path} (${dbDoc._id.substring(0, 8)}, ${dbDoc._rev?.substring(0, 10)}) `, + LOG_LEVEL_NOTICE + ); + Logger(e, LOG_LEVEL_VERBOSE); + return true; + } + } + return true; + } + databaseQueuedProcessor = new QueueProcessor( async (docs: EntryBody[]) => { const dbDoc = docs[0] as LoadedEntry; // It has no `data` const path = getPath(dbDoc); - + // If the document is existing with any revision, confirm that we have to process it. + const isRequired = await this.checkIsChangeRequiredForDatabaseProcessing(dbDoc); + if (!isRequired) { + Logger(`Skipped (Not latest): ${path} (${dbDoc._id.substring(0, 8)})`, LOG_LEVEL_VERBOSE); + return; + } // If `Read chunks online` is disabled, chunks should be transferred before here. // However, in some cases, chunks are after that. So, if missing chunks exist, we have to wait for them. const doc = await this.localDatabase.getDBEntryFromMeta({ ...dbDoc }, false, true); @@ -503,6 +572,10 @@ Even if you choose to clean up, you will see this option again if you exit Obsid return !checkResult; } + private _reportUnresolvedMessages(): Promise { + return Promise.resolve([...this._previousErrors]); + } + onBindFunction(core: LiveSyncCore, services: typeof core.services): void { services.replicator.handleGetActiveReplicator(this._getReplicator.bind(this)); services.databaseEvents.handleOnDatabaseInitialisation(this._everyOnInitializeDatabase.bind(this)); @@ -516,5 +589,6 @@ Even if you choose to clean up, you will see this option again if you exit Obsid services.replication.handleReplicateByEvent(this._replicateByEvent.bind(this)); services.remote.handleReplicateAllToRemote(this._replicateAllToServer.bind(this)); services.remote.handleReplicateAllFromRemote(this._replicateAllFromServer.bind(this)); + services.appLifecycle.reportUnresolvedMessages(this._reportUnresolvedMessages.bind(this)); } } diff --git a/src/modules/essentialObsidian/ModuleObsidianAPI.ts b/src/modules/essentialObsidian/ModuleObsidianAPI.ts index 981b321..03d1a1c 100644 --- a/src/modules/essentialObsidian/ModuleObsidianAPI.ts +++ b/src/modules/essentialObsidian/ModuleObsidianAPI.ts @@ -1,5 +1,12 @@ import { AbstractObsidianModule } from "../AbstractObsidianModule.ts"; -import { LOG_LEVEL_DEBUG, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger"; +import { + LEVEL_INFO, + LEVEL_NOTICE, + LOG_LEVEL_DEBUG, + LOG_LEVEL_NOTICE, + LOG_LEVEL_VERBOSE, + type LOG_LEVEL, +} from "octagonal-wheels/common/logger"; import { Notice, requestUrl, type RequestUrlParam, type RequestUrlResponse } from "../../deps.ts"; import { type CouchDBCredentials, type EntryDoc, type FilePath } from "../../lib/src/common/types.ts"; import { getPathFromTFile } from "../../common/utils.ts"; @@ -12,6 +19,7 @@ import { ObsHttpHandler } from "./APILib/ObsHttpHandler.ts"; import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser.ts"; import { AuthorizationHeaderGenerator } from "../../lib/src/replication/httplib.ts"; import type { LiveSyncCore } from "../../main.ts"; +import { EVENT_ON_UNRESOLVED_ERROR, eventHub } from "../../common/events.ts"; setNoticeClass(Notice); @@ -24,7 +32,20 @@ export class ModuleObsidianAPI extends AbstractObsidianModule { _customHandler!: ObsHttpHandler; _authHeader = new AuthorizationHeaderGenerator(); + _previousErrors = new Set(); + showError(msg: string, max_log_level: LOG_LEVEL = LEVEL_NOTICE) { + const level = this._previousErrors.has(msg) ? LEVEL_INFO : max_log_level; + this._log(msg, level); + if (!this._previousErrors.has(msg)) { + this._previousErrors.add(msg); + eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR); + } + } + clearErrors() { + this._previousErrors.clear(); + eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR); + } last_successful_post = false; _customFetchHandler(): ObsHttpHandler { if (!this._customHandler) this._customHandler = new ObsHttpHandler(undefined, undefined); @@ -180,6 +201,7 @@ export class ModuleObsidianAPI extends AbstractObsidianModule { } } } + this.clearErrors(); return response; } catch (ex) { if (ex instanceof TypeError) { @@ -195,7 +217,7 @@ export class ModuleObsidianAPI extends AbstractObsidianModule { headers, }); if (resp2.status / 100 == 2) { - this._log( + this.showError( "The request was successful by API. But the native fetch API failed! Please check CORS settings on the remote database!. While this condition, you cannot enable LiveSync", LOG_LEVEL_NOTICE ); @@ -203,7 +225,7 @@ export class ModuleObsidianAPI extends AbstractObsidianModule { } const r2 = resp2.clone(); const msg = await r2.text(); - this._log(`Failed to fetch by API. ${resp2.status}: ${msg}`, LOG_LEVEL_NOTICE); + this.showError(`Failed to fetch by API. ${resp2.status}: ${msg}`, LOG_LEVEL_NOTICE); return resp2; } throw ex; @@ -211,7 +233,7 @@ export class ModuleObsidianAPI extends AbstractObsidianModule { } catch (ex: any) { this._log(`HTTP:${method}${size} to:${localURL} -> failed`, LOG_LEVEL_VERBOSE); const msg = ex instanceof Error ? `${ex?.name}:${ex?.message}` : ex?.toString(); - this._log(`Failed to fetch: ${msg}`, LOG_LEVEL_NOTICE); + this.showError(`Failed to fetch: ${msg}`); // Do not show notice, due to throwing below this._log(ex, LOG_LEVEL_VERBOSE); // limit only in bulk_docs. if (url.toString().indexOf("_bulk_docs") !== -1) { @@ -279,6 +301,10 @@ export class ModuleObsidianAPI extends AbstractObsidianModule { return `${"appId" in this.app ? this.app.appId : ""}`; } + private _reportUnresolvedMessages(): Promise { + return Promise.resolve([...this._previousErrors]); + } + onBindFunction(core: LiveSyncCore, services: typeof core.services) { services.API.handleGetCustomFetchHandler(this._customFetchHandler.bind(this)); services.API.handleIsLastPostFailedDueToPayloadSize(this._getLastPostFailedBySize.bind(this)); @@ -288,5 +314,6 @@ export class ModuleObsidianAPI extends AbstractObsidianModule { services.vault.handleVaultName(this._vaultName.bind(this)); services.vault.handleGetActiveFilePath(this._getActiveFilePath.bind(this)); services.API.handleGetAppID(this._anyGetAppId.bind(this)); + services.appLifecycle.reportUnresolvedMessages(this._reportUnresolvedMessages.bind(this)); } } diff --git a/src/modules/features/ModuleLog.ts b/src/modules/features/ModuleLog.ts index 48928e4..e65eef7 100644 --- a/src/modules/features/ModuleLog.ts +++ b/src/modules/features/ModuleLog.ts @@ -19,7 +19,12 @@ import { logMessages, } from "../../lib/src/mock_and_interop/stores.ts"; import { eventHub } from "../../lib/src/hub/hub.ts"; -import { EVENT_FILE_RENAMED, EVENT_LAYOUT_READY, EVENT_LEAF_ACTIVE_CHANGED } from "../../common/events.ts"; +import { + EVENT_FILE_RENAMED, + EVENT_LAYOUT_READY, + EVENT_LEAF_ACTIVE_CHANGED, + EVENT_ON_UNRESOLVED_ERROR, +} from "../../common/events.ts"; import { AbstractObsidianModule } from "../AbstractObsidianModule.ts"; import { addIcon, normalizePath, Notice } from "../../deps.ts"; import { LOG_LEVEL_NOTICE, setGlobalLogFunction } from "octagonal-wheels/common/logger"; @@ -198,11 +203,13 @@ export class ModuleLog extends AbstractObsidianModule { this.applyStatusBarText(); }, 20); statusBarLabels.onChanged((label) => applyToDisplay(label.value)); + this.activeFileStatus.onChanged(() => this.updateMessageArea()); } private _everyOnload(): Promise { eventHub.onEvent(EVENT_LEAF_ACTIVE_CHANGED, () => this.onActiveLeafChange()); eventHub.onceEvent(EVENT_LAYOUT_READY, () => this.onActiveLeafChange()); + eventHub.onEvent(EVENT_ON_UNRESOLVED_ERROR, () => this.updateMessageArea()); return Promise.resolve(true); } @@ -234,8 +241,19 @@ export class ModuleLog extends AbstractObsidianModule { async setFileStatus() { const fileStatus = await this.getActiveFileStatus(); this.activeFileStatus.value = fileStatus; - this.messageArea!.innerText = this.settings.hideFileWarningNotice ? "" : fileStatus; } + + async updateMessageArea() { + if (this.messageArea) { + const messageLines = []; + const fileStatus = this.activeFileStatus.value; + if (fileStatus && !this.settings.hideFileWarningNotice) messageLines.push(fileStatus); + const messages = (await this.services.appLifecycle.getUnresolvedMessages()).flat().filter((e) => e); + messageLines.push(...messages); + this.messageArea.innerText = messageLines.map((e) => `⚠️ ${e}`).join("\n"); + } + } + onActiveLeafChange() { fireAndForget(async () => { this.adjustStatusDivPosition(); diff --git a/src/modules/features/SettingDialogue/settingUtils.ts b/src/modules/features/SettingDialogue/settingUtils.ts index 99afea5..79e7173 100644 --- a/src/modules/features/SettingDialogue/settingUtils.ts +++ b/src/modules/features/SettingDialogue/settingUtils.ts @@ -41,7 +41,7 @@ export function getBucketConfigSummary(setting: ObsidianLiveSyncSettings, showAd */ export function getCouchDBConfigSummary(setting: ObsidianLiveSyncSettings, showAdvanced = false) { const settingTable: Partial = pickCouchDBSyncSettings(setting); - return getSummaryFromPartialSettings(settingTable, showAdvanced); + return getSummaryFromPartialSettings(settingTable, showAdvanced || setting.useJWT); } /** diff --git a/src/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte b/src/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte index 0710214..0e44f87 100644 --- a/src/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte +++ b/src/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte @@ -223,7 +223,7 @@ - + - + > @@ -254,7 +256,7 @@ diff --git a/styles.css b/styles.css index 78f5968..1a51817 100644 --- a/styles.css +++ b/styles.css @@ -414,12 +414,19 @@ div.workspace-leaf-content[data-type=bases] .livesync-status { } -.livesync-status div.livesync-status-messagearea { +.livesync-status div.livesync-status-messagearea:empty { + display: none; +} + +.livesync-status div.livesync-status-messagearea:not(:empty) { opacity: 0.6; color: var(--text-on-accent); - background: var(--background-modifier-error); + border: 1px solid var(--background-modifier-error); + background-color: rgba(var(--background-modifier-error-rgb), 0.2); -webkit-filter: unset; filter: unset; + width: fit-content; + margin-left: auto; }