mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-06-10 16:30:15 +00:00
7d2ba1b0b9
- Database fetching (a.k.a. Reset Synchronisation on This Device) on the initialisation now supports streaming and is faster (CouchDB only) - The database fetching process has been streamlined, and database operations are now suspended until it has been completed - The initial synchronisation process has been simplified, making it easier to synchronise files with the remote server - We can select the remote database to fetch from during the initialisation, when there are multiple remote databases configured (e.g. multiple CouchDBs or S3 remotes)
491 lines
19 KiB
TypeScript
491 lines
19 KiB
TypeScript
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
|
import type { NecessaryServices } from "@lib/interfaces/ServiceModule";
|
|
import { createInstanceLogFunction, type LogFunction } from "@lib/services/lib/logUtils";
|
|
import { FlagFilesHumanReadable, FlagFilesOriginal } from "@lib/common/models/redflag.const";
|
|
import FetchEverything from "../modules/features/SetupWizard/dialogs/FetchEverything.svelte";
|
|
import RebuildEverything from "../modules/features/SetupWizard/dialogs/RebuildEverything.svelte";
|
|
import { extractObject } from "octagonal-wheels/object";
|
|
import { REMOTE_MINIO, REMOTE_P2P } from "@lib/common/models/setting.const";
|
|
import type { ObsidianLiveSyncSettings } from "@lib/common/models/setting.type";
|
|
import { TweakValuesShouldMatchedTemplate } from "@lib/common/models/tweak.definition";
|
|
import type {
|
|
FetchEverythingResult,
|
|
RebuildEverythingResult,
|
|
} from "@/modules/features/SetupWizard/dialogs/setupDialogTypes";
|
|
import { askAndPerformFastSetupOnScheduledFetchAll } from "./redFlag.simpleFetch";
|
|
import { ConnectionStringParser } from "@lib/common/ConnectionString";
|
|
import { activateRemoteConfiguration } from "@lib/serviceFeatures/remoteConfig";
|
|
|
|
/**
|
|
* Flag file handler interface, similar to target filter pattern.
|
|
*/
|
|
interface FlagFileHandler {
|
|
priority: number;
|
|
check: () => Promise<boolean>;
|
|
handle: () => Promise<boolean>;
|
|
}
|
|
|
|
export async function isFlagFileExist(host: NecessaryServices<never, "storageAccess">, path: string) {
|
|
const redFlagExist = await host.serviceModules.storageAccess.isExists(
|
|
host.serviceModules.storageAccess.normalisePath(path)
|
|
);
|
|
if (redFlagExist) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export async function deleteFlagFile(host: NecessaryServices<never, "storageAccess">, log: LogFunction, path: string) {
|
|
try {
|
|
const isFlagged = await host.serviceModules.storageAccess.isExists(
|
|
host.serviceModules.storageAccess.normalisePath(path)
|
|
);
|
|
if (isFlagged) {
|
|
await host.serviceModules.storageAccess.delete(path, true);
|
|
}
|
|
} catch (ex) {
|
|
log(`Could not delete ${path}`);
|
|
log(ex, LOG_LEVEL_VERBOSE);
|
|
}
|
|
}
|
|
const REMOTE_KEEP_CURRENT = "Use active remote";
|
|
const REMOTE_CANCEL = "Cancel";
|
|
async function askAndActivateRemoteDatabase(host: NecessaryServices<"UI" | "setting", any>, log: LogFunction) {
|
|
const settings = host.services.setting.currentSettings();
|
|
if (settings.remoteConfigurations && Object.keys(settings.remoteConfigurations).length > 1) {
|
|
const message =
|
|
"Multiple remote configurations detected. Please select the remote configuration you want to fetch from.";
|
|
const options = Object.entries(settings.remoteConfigurations).map(([id, config]) => {
|
|
const parsed = ConnectionStringParser.parse(config.uri);
|
|
const displayURI = (config.uri.split("@").pop() || "").substring(0, 20) + "..."; // Show only the last part of URI for better readability and privacy.
|
|
return {
|
|
name: `${config.name} - ${parsed.type} (${displayURI})`,
|
|
id: id,
|
|
};
|
|
});
|
|
options.push({
|
|
name: REMOTE_KEEP_CURRENT,
|
|
id: "keep_current",
|
|
});
|
|
options.push({
|
|
name: REMOTE_CANCEL,
|
|
id: "cancel",
|
|
});
|
|
|
|
const selections = options.map((option) => option.name);
|
|
// const defaultAction =
|
|
// options.find((option) => option.id === settings.activeConfigurationId)?.name || selections[0];
|
|
const selectedId = await host.services.UI.confirm.askSelectStringDialogue(message, selections, {
|
|
title: "Select Remote Configuration",
|
|
defaultAction: REMOTE_KEEP_CURRENT,
|
|
});
|
|
const selectedConfig = options.find((option) => option.name === selectedId);
|
|
if (selectedConfig) {
|
|
if (selectedConfig.id === "keep_current") {
|
|
log(`Keeping current remote configuration.`, LOG_LEVEL_INFO);
|
|
return true;
|
|
}
|
|
if (selectedConfig.id === "cancel") {
|
|
log(`Remote configuration selection cancelled.`, LOG_LEVEL_NOTICE);
|
|
return false;
|
|
}
|
|
const activated = activateRemoteConfiguration(settings, selectedConfig.id);
|
|
if (activated) {
|
|
await host.services.setting.applyPartial(activated);
|
|
log(`Activated remote configuration: ${selectedConfig.name}`, LOG_LEVEL_INFO);
|
|
return true;
|
|
} else {
|
|
log(`Failed to activate remote configuration: ${selectedConfig.name}`, LOG_LEVEL_NOTICE);
|
|
return false;
|
|
}
|
|
} else {
|
|
log(`No remote configuration selected.`, LOG_LEVEL_NOTICE);
|
|
return false;
|
|
}
|
|
}
|
|
return true; // If there is only one or no remote configuration, proceed without asking.
|
|
}
|
|
/**
|
|
* Factory function to create a fetch all flag handler.
|
|
* All logic related to fetch all flag is encapsulated here.
|
|
*/
|
|
export function createFetchAllFlagHandler(
|
|
host: NecessaryServices<
|
|
| "vault"
|
|
| "fileProcessing"
|
|
| "tweakValue"
|
|
| "UI"
|
|
| "setting"
|
|
| "appLifecycle"
|
|
| "path"
|
|
| "keyValueDB"
|
|
| "database",
|
|
"storageAccess" | "rebuilder" | "fileHandler"
|
|
>,
|
|
log: LogFunction
|
|
): FlagFileHandler {
|
|
// Check if fetch all flag is active
|
|
const isFlagActive = async () =>
|
|
(await isFlagFileExist(host, FlagFilesOriginal.FETCH_ALL)) ||
|
|
(await isFlagFileExist(host, FlagFilesHumanReadable.FETCH_ALL));
|
|
|
|
// Cleanup fetch all flag files
|
|
const cleanupFlag = async () => {
|
|
await deleteFlagFile(host, log, FlagFilesOriginal.FETCH_ALL);
|
|
await deleteFlagFile(host, log, FlagFilesHumanReadable.FETCH_ALL);
|
|
};
|
|
|
|
// Handle the fetch all scheduled operation
|
|
const onScheduled = async () => {
|
|
// Select the remote database if there are multiple remotes configured.
|
|
const isRemoteActivated = await askAndActivateRemoteDatabase(host, log);
|
|
if (!isRemoteActivated) {
|
|
return false;
|
|
}
|
|
|
|
// Ask user for use Fast Setup
|
|
const useFastSetup = await askAndPerformFastSetupOnScheduledFetchAll(host, log, cleanupFlag);
|
|
if (useFastSetup !== undefined) {
|
|
return useFastSetup;
|
|
}
|
|
// if useFastSetup is undefined, it means user choose to proceed with normal fetch process, so continue to ask for fetch method.
|
|
|
|
const method =
|
|
await host.services.UI.dialogManager.openWithExplicitCancel<FetchEverythingResult>(FetchEverything);
|
|
if (method === "cancelled") {
|
|
log("Fetch everything cancelled by user.", LOG_LEVEL_NOTICE);
|
|
await cleanupFlag();
|
|
host.services.appLifecycle.performRestart();
|
|
return false;
|
|
}
|
|
const { vault, extra } = method;
|
|
const settings = await Promise.resolve(host.services.setting.currentSettings());
|
|
// If remote is MinIO, makeLocalChunkBeforeSync is not available. (because no-deduplication on sending).
|
|
const makeLocalChunkBeforeSyncAvailable = settings.remoteType !== REMOTE_MINIO;
|
|
const mapVaultStateToAction = {
|
|
identical: {
|
|
makeLocalChunkBeforeSync: makeLocalChunkBeforeSyncAvailable,
|
|
makeLocalFilesBeforeSync: false,
|
|
},
|
|
independent: {
|
|
makeLocalChunkBeforeSync: false,
|
|
makeLocalFilesBeforeSync: false,
|
|
},
|
|
unbalanced: {
|
|
makeLocalChunkBeforeSync: false,
|
|
makeLocalFilesBeforeSync: true,
|
|
},
|
|
cancelled: {
|
|
makeLocalChunkBeforeSync: false,
|
|
makeLocalFilesBeforeSync: false,
|
|
},
|
|
} as const;
|
|
|
|
return await processVaultInitialisation(host, log, async () => {
|
|
const settings = host.services.setting.currentSettings();
|
|
await adjustSettingToRemoteIfNeeded(host, log, extra, settings);
|
|
const vaultStateToAction = mapVaultStateToAction[vault];
|
|
const { makeLocalChunkBeforeSync, makeLocalFilesBeforeSync } = vaultStateToAction;
|
|
log(
|
|
`Fetching everything with settings: makeLocalChunkBeforeSync=${makeLocalChunkBeforeSync}, makeLocalFilesBeforeSync=${makeLocalFilesBeforeSync}`,
|
|
LOG_LEVEL_INFO
|
|
);
|
|
await host.serviceModules.rebuilder.$fetchLocal(makeLocalChunkBeforeSync, !makeLocalFilesBeforeSync);
|
|
await cleanupFlag();
|
|
log("Fetch everything operation completed. Vault files will be gradually synced.", LOG_LEVEL_NOTICE);
|
|
return true;
|
|
});
|
|
};
|
|
|
|
return {
|
|
priority: 10,
|
|
check: () => isFlagActive(),
|
|
handle: async () => {
|
|
const res = await onScheduled();
|
|
if (res) {
|
|
return await verifyAndUnlockSuspension(host, log);
|
|
}
|
|
return false;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Adjust setting to remote configuration.
|
|
* @param config current configuration to retrieve remote preferred config
|
|
* @returns updated configuration if applied, otherwise null.
|
|
*/
|
|
export async function adjustSettingToRemote(
|
|
host: NecessaryServices<"tweakValue" | "UI" | "setting", any>,
|
|
log: LogFunction,
|
|
config: ObsidianLiveSyncSettings
|
|
) {
|
|
// Fetch remote configuration unless prevented.
|
|
const SKIP_FETCH = "Skip and proceed";
|
|
const RETRY_FETCH = "Retry (recommended)";
|
|
let canProceed = false;
|
|
do {
|
|
const remoteTweaks = await host.services.tweakValue.fetchRemotePreferred(config);
|
|
if (!remoteTweaks) {
|
|
const choice = await host.services.UI.confirm.askSelectStringDialogue(
|
|
"Could not fetch configuration from remote. If you are new to the Self-hosted LiveSync, this might be expected. If not, you should check your network or server settings.",
|
|
[SKIP_FETCH, RETRY_FETCH] as const,
|
|
{
|
|
defaultAction: RETRY_FETCH,
|
|
timeout: 0,
|
|
title: "Fetch Remote Configuration Failed",
|
|
}
|
|
);
|
|
if (choice === SKIP_FETCH) {
|
|
canProceed = true;
|
|
}
|
|
} else {
|
|
const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks);
|
|
// Check if any necessary tweak value is different from current config.
|
|
const differentItems = Object.entries(necessary).filter(([key, value]) => {
|
|
return (config as any)[key] !== value;
|
|
});
|
|
if (differentItems.length === 0) {
|
|
log("Remote configuration matches local configuration. No changes applied.", LOG_LEVEL_NOTICE);
|
|
} else {
|
|
await host.services.UI.confirm.askSelectStringDialogue(
|
|
"Your settings differed slightly from the server's. The plug-in has supplemented the incompatible parts with the server settings!",
|
|
["OK"] as const,
|
|
{
|
|
defaultAction: "OK",
|
|
timeout: 0,
|
|
}
|
|
);
|
|
}
|
|
|
|
config = {
|
|
...config,
|
|
...Object.fromEntries(differentItems),
|
|
} satisfies ObsidianLiveSyncSettings;
|
|
await host.services.setting.applyExternalSettings(config, true);
|
|
log("Remote configuration applied.", LOG_LEVEL_NOTICE);
|
|
canProceed = true;
|
|
const updatedConfig = host.services.setting.currentSettings();
|
|
return updatedConfig;
|
|
}
|
|
} while (!canProceed);
|
|
}
|
|
|
|
/**
|
|
* Adjust setting to remote if needed.
|
|
* @param extra result of dialogues that may contain preventFetchingConfig flag (e.g, from FetchEverything or RebuildEverything)
|
|
* @param config current configuration to retrieve remote preferred config
|
|
*/
|
|
export async function adjustSettingToRemoteIfNeeded(
|
|
host: NecessaryServices<"tweakValue" | "UI" | "setting", any>,
|
|
log: LogFunction,
|
|
extra: { preventFetchingConfig: boolean },
|
|
config: ObsidianLiveSyncSettings
|
|
) {
|
|
if (extra && extra.preventFetchingConfig) {
|
|
return;
|
|
}
|
|
|
|
// P2P has no centralised remote configuration; skip to avoid a spurious
|
|
// "Failed to connect to the remote server" error dialog.
|
|
if (config.remoteType === REMOTE_P2P) {
|
|
log("Remote configuration fetch skipped (P2P mode).", LOG_LEVEL_INFO);
|
|
return;
|
|
}
|
|
|
|
// Remote configuration fetched and applied.
|
|
if (await adjustSettingToRemote(host, log, config)) {
|
|
config = host.services.setting.currentSettings();
|
|
} else {
|
|
log("Remote configuration not applied.", LOG_LEVEL_NOTICE);
|
|
}
|
|
// log(JSON.stringify(config), LOG_LEVEL_VERBOSE);
|
|
}
|
|
|
|
/**
|
|
* Process vault initialisation with suspending file watching and sync.
|
|
* @param proc process to be executed during initialisation, should return true if can be continued, false if app is unable to continue the process.
|
|
* @param keepSuspending whether to keep suspending file watching after the process.
|
|
* @returns result of the process, or false if error occurs.
|
|
*/
|
|
export async function processVaultInitialisation(
|
|
host: NecessaryServices<"setting", any>,
|
|
log: LogFunction,
|
|
proc: () => Promise<boolean>,
|
|
keepSuspending = false
|
|
) {
|
|
try {
|
|
// Disable batch saving and file watching during initialisation.
|
|
await host.services.setting.applyPartial({ batchSave: false }, false);
|
|
await host.services.setting.suspendAllSync();
|
|
await host.services.setting.suspendExtraSync();
|
|
await host.services.setting.applyPartial({ suspendFileWatching: true }, true);
|
|
try {
|
|
const result = await proc();
|
|
return result;
|
|
} catch (ex) {
|
|
log("Error during vault initialisation process.", LOG_LEVEL_NOTICE);
|
|
log(ex, LOG_LEVEL_VERBOSE);
|
|
return false;
|
|
}
|
|
} catch (ex) {
|
|
log("Error during vault initialisation.", LOG_LEVEL_NOTICE);
|
|
log(ex, LOG_LEVEL_VERBOSE);
|
|
return false;
|
|
} finally {
|
|
if (!keepSuspending) {
|
|
// Re-enable file watching after initialisation.
|
|
await host.services.setting.applyPartial({ suspendFileWatching: false }, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function verifyAndUnlockSuspension(
|
|
host: NecessaryServices<"setting" | "appLifecycle" | "UI", any>,
|
|
log: LogFunction
|
|
) {
|
|
if (!host.services.setting.currentSettings().suspendFileWatching) {
|
|
return true;
|
|
}
|
|
if (
|
|
(await host.services.UI.confirm.askYesNoDialog(
|
|
"Do you want to resume file and database processing, and restart obsidian now?",
|
|
{ defaultOption: "Yes", timeout: 15 }
|
|
)) != "yes"
|
|
) {
|
|
// TODO: Confirm actually proceed to next process.
|
|
return true;
|
|
}
|
|
await host.services.setting.applyPartial({ suspendFileWatching: false }, true);
|
|
host.services.appLifecycle.performRestart();
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Factory function to create a rebuild flag handler.
|
|
* All logic related to rebuild flag is encapsulated here.
|
|
*/
|
|
export function createRebuildFlagHandler(
|
|
host: NecessaryServices<"setting" | "appLifecycle" | "UI" | "tweakValue", "storageAccess" | "rebuilder">,
|
|
log: LogFunction
|
|
) {
|
|
// Check if rebuild flag is active
|
|
const isFlagActive = async () =>
|
|
(await isFlagFileExist(host, FlagFilesOriginal.REBUILD_ALL)) ||
|
|
(await isFlagFileExist(host, FlagFilesHumanReadable.REBUILD_ALL));
|
|
|
|
// Cleanup rebuild flag files
|
|
const cleanupFlag = async () => {
|
|
await deleteFlagFile(host, log, FlagFilesOriginal.REBUILD_ALL);
|
|
await deleteFlagFile(host, log, FlagFilesHumanReadable.REBUILD_ALL);
|
|
};
|
|
|
|
// Handle the rebuild everything scheduled operation
|
|
const onScheduled = async () => {
|
|
const method =
|
|
await host.services.UI.dialogManager.openWithExplicitCancel<RebuildEverythingResult>(RebuildEverything);
|
|
if (method === "cancelled") {
|
|
log("Rebuild everything cancelled by user.", LOG_LEVEL_NOTICE);
|
|
await cleanupFlag();
|
|
host.services.appLifecycle.performRestart();
|
|
return false;
|
|
}
|
|
const { extra } = method;
|
|
const settings = host.services.setting.currentSettings();
|
|
await adjustSettingToRemoteIfNeeded(host, log, extra, settings);
|
|
return await processVaultInitialisation(host, log, async () => {
|
|
await host.serviceModules.rebuilder.$rebuildEverything();
|
|
await cleanupFlag();
|
|
log("Rebuild everything operation completed.", LOG_LEVEL_NOTICE);
|
|
return true;
|
|
});
|
|
};
|
|
|
|
return {
|
|
priority: 20,
|
|
check: () => isFlagActive(),
|
|
handle: async () => {
|
|
const res = await onScheduled();
|
|
if (res) {
|
|
return await verifyAndUnlockSuspension(host, log);
|
|
}
|
|
return false;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Factory function to create a suspend all flag handler.
|
|
* All logic related to suspend flag is encapsulated here.
|
|
*/
|
|
export function createSuspendFlagHandler(
|
|
host: NecessaryServices<"setting", "storageAccess">,
|
|
log: LogFunction
|
|
): FlagFileHandler {
|
|
// Check if suspend flag is active
|
|
const isFlagActive = async () => await isFlagFileExist(host, FlagFilesOriginal.SUSPEND_ALL);
|
|
|
|
// Handle the suspend all scheduled operation
|
|
const onScheduled = async () => {
|
|
log("SCRAM is detected. All operations are suspended.", LOG_LEVEL_NOTICE);
|
|
return await processVaultInitialisation(
|
|
host,
|
|
log,
|
|
async () => {
|
|
log(
|
|
"All operations are suspended as per SCRAM.\nLogs will be written to the file. This might be a performance impact.",
|
|
LOG_LEVEL_NOTICE
|
|
);
|
|
await host.services.setting.applyPartial({ writeLogToTheFile: true }, true);
|
|
return Promise.resolve(false);
|
|
},
|
|
true
|
|
);
|
|
};
|
|
|
|
return {
|
|
priority: 5,
|
|
check: () => isFlagActive(),
|
|
handle: () => onScheduled(),
|
|
};
|
|
}
|
|
|
|
export function flagHandlerToEventHandler(flagHandler: FlagFileHandler) {
|
|
return async () => {
|
|
if (await flagHandler.check()) {
|
|
return await flagHandler.handle();
|
|
}
|
|
return true;
|
|
};
|
|
}
|
|
|
|
export function useRedFlagFeatures(
|
|
host: NecessaryServices<
|
|
| "API"
|
|
| "appLifecycle"
|
|
| "UI"
|
|
| "setting"
|
|
| "tweakValue"
|
|
| "fileProcessing"
|
|
| "vault"
|
|
| "path"
|
|
| "keyValueDB"
|
|
| "database",
|
|
"storageAccess" | "rebuilder" | "fileHandler"
|
|
>
|
|
) {
|
|
const log = createInstanceLogFunction("SF:RedFlag", host.services.API);
|
|
const handlerFetch = createFetchAllFlagHandler(host, log);
|
|
const handlerRebuild = createRebuildFlagHandler(host, log);
|
|
const handlerSuspend = createSuspendFlagHandler(host, log);
|
|
host.services.appLifecycle.onLayoutReady.addHandler(flagHandlerToEventHandler(handlerFetch), handlerFetch.priority);
|
|
host.services.appLifecycle.onLayoutReady.addHandler(
|
|
flagHandlerToEventHandler(handlerRebuild),
|
|
handlerRebuild.priority
|
|
);
|
|
host.services.appLifecycle.onLayoutReady.addHandler(
|
|
flagHandlerToEventHandler(handlerSuspend),
|
|
handlerSuspend.priority
|
|
);
|
|
}
|