Compare commits

..

13 Commits

Author SHA1 Message Date
vorotamoroz 6a9918677f Merge pull request #996 from vrtmrz/0_25_80
releasing 0.25.80
2026-07-07 21:21:34 +09:00
vorotamoroz 2d42b92a89 bump 2026-07-07 12:15:58 +00:00
vorotamoroz a2b794c520 Merge pull request #995 from vrtmrz/fix_conflict_issues
Fix conflict issues
2026-07-07 21:13:00 +09:00
vorotamoroz 6f9446f447 Merge remote-tracking branch 'origin/main' into fix_conflict_issues 2026-07-07 12:11:38 +00:00
vorotamoroz 3f9cd67b1c Merge pull request #992 from vrtmrz/fix_989
fix: enable hidden file sync before overwrite setup
2026-07-07 21:10:09 +09:00
vorotamoroz dbcbf2c5ca fix: document safer conflict preservation 2026-07-07 12:05:40 +00:00
vorotamoroz eed0fca8d3 fix: preserve ambiguous conflict candidates 2026-07-07 12:00:24 +00:00
vorotamoroz 05e031b90b update submodule pointer 2026-07-07 11:31:29 +00:00
vorotamoroz bec767c13f add notes 2026-07-07 11:30:36 +00:00
vorotamoroz 8046a777af fix: refine conflict merge policy 2026-07-07 11:29:51 +00:00
vorotamoroz 0c58b0c513 test: reproduce conflict issue reports 2026-07-07 11:07:09 +00:00
vorotamoroz b66e227a02 update doc
update submodule pointer
2026-07-07 10:54:49 +00:00
vorotamoroz af6df84b5d fix: enable hidden file sync before overwrite setup 2026-07-03 05:09:55 +00:00
33 changed files with 247 additions and 553 deletions
+14
View File
@@ -148,6 +148,20 @@ Hence, the new feature should be implemented as follows:
- **Service Hub** (`src/modules/services/`): Central service registry using dependency injection
- **Common Library** (`src/lib/`): Platform-independent sync logic, shared with other tools
### Conflict Merge Policy
Markdown conflict auto-merge should behave like a conservative three-way merge. The guiding rule is to merge changes when they touch non-overlapping regions, and to keep a manual conflict when the edits overlap semantically.
When in doubt, prefer the safer outcome: preserve data, keep the conflict visible, and ask the user rather than silently discarding content or choosing one side.
- If one side deletes a line and the other side leaves that same line unchanged, treat it as a safe deletion. The deleted line must not be reintroduced into the merged result.
- If one side inserts new content in a different region while the other side deletes an unchanged old region, preserve the insertion and the deletion.
- If one side deletes a line and the other side modifies that same line, keep the conflict for user resolution.
- If both sides insert different content at the same position, keep both insertions in a deterministic order unless the surrounding deletion context indicates that they are competing replacements.
- Avoid resolving conflicts by simply choosing the newest revision unless the user has explicitly selected that behaviour.
This policy is intentionally aligned with the conflict checkboxes and compatibility settings: automatic merge should remove avoidable prompts, but it must not silently choose between overlapping user intentions.
### File Structure Conventions
- **Platform-specific code**: Use `.platform.ts` suffix (replaced with `.obsidian.ts` in production builds via esbuild)
+1 -7
View File
@@ -153,7 +153,7 @@ Show verbose log. Please enable when you report the logs
Self-hosted LiveSync supports multiple remote connection profiles under **Remote Server** -> **Remote Databases**. This allows you to save and switch between multiple databases or bucket configurations in a single vault.
- ** Add new connection**: Create a new connection profile by launching the setup dialogue.
- **📥 Import connection**: Paste a connection string (e.g., `sls+https://...`, `sls+s3://...`, `sls+webdav://...`, `sls+p2p://...`) to import a remote configuration profile.
- **📥 Import connection**: Paste a connection string (e.g., `sls+https://...`, `sls+s3://...`, `sls+p2p://...`) to import a remote configuration profile.
- **🔧 Configure**: Open the setup dialogue to edit settings for the selected connection profile.
- **✅ Activate**: Select and activate this profile as the current active remote.
- **🗑️ Delete**: Remove this connection profile from the list.
@@ -164,12 +164,6 @@ Setting key: remoteType
The active remote server type. This is automatically projected to the legacy configuration when you activate a connection profile.
#### WebDAV Connection URI (Experimental)
Setting key: webDAVactiveConnectionURI
The active WebDAV connection URI used by experimental Journal synchronisation storage. This is configured in the **WebDAV Configuration** setup dialogue as a single `sls+webdav://...` URI. Optional query parameters include `prefix`, `useProxy=true`, and `insecure=true` for local HTTP testing. This feature is still a proof of concept for pluggable Journal storage backends and should be treated as experimental.
### 2. Notification
#### Notify when the estimated remote storage size exceeds on start up
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "0.25.79",
"version": "0.25.80",
"minAppVersion": "1.7.2",
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"author": "vorotamoroz",
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "obsidian-livesync",
"version": "0.25.79",
"version": "0.25.80",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-livesync",
"version": "0.25.79",
"version": "0.25.80",
"license": "MIT",
"workspaces": [
"src/apps/cli",
@@ -16210,7 +16210,7 @@
},
"src/apps/cli": {
"name": "self-hosted-livesync-cli",
"version": "0.25.79-cli",
"version": "0.25.80-cli",
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
@@ -16236,7 +16236,7 @@
},
"src/apps/webapp": {
"name": "livesync-webapp",
"version": "0.25.79-webapp",
"version": "0.25.80-webapp",
"dependencies": {
"octagonal-wheels": "^0.1.47"
},
@@ -16251,7 +16251,7 @@
}
},
"src/apps/webpeer": {
"version": "0.25.79-webpeer",
"version": "0.25.80-webpeer",
"dependencies": {
"octagonal-wheels": "^0.1.47"
},
+1 -6
View File
@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
"version": "0.25.79",
"version": "0.25.80",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"main": "main.js",
"type": "module",
@@ -64,11 +64,6 @@
"test:docker-s3:start": "npm run test:docker-s3:up && sleep 3 && npm run test:docker-s3:init",
"test:docker-s3:down": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/minio-stop.sh",
"test:docker-s3:stop": "npm run test:docker-s3:down",
"test:docker-webdav:up": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/webdav-start.sh",
"test:docker-webdav:init": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/webdav-init.sh",
"test:docker-webdav:start": "npm run test:docker-webdav:up && sleep 3 && npm run test:docker-webdav:init",
"test:docker-webdav:down": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/webdav-stop.sh",
"test:docker-webdav:stop": "npm run test:docker-webdav:down",
"test:docker-p2p:up": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/p2p-start.sh",
"test:docker-p2p:init": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/p2p-init.sh",
"test:docker-p2p:start": "npm run test:docker-p2p:up && sleep 3 && npm run test:docker-p2p:init",
+1 -21
View File
@@ -4,7 +4,7 @@ import * as os from "os";
import * as processSetting from "@lib/API/processSetting";
import { ConnectionStringParser } from "@lib/common/ConnectionString";
import { configURIBase } from "@lib/common/models/shared.const";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P, REMOTE_WEBDAV } from "@lib/common/types";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P } from "@lib/common/types";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { runCommand } from "./runCommand";
import type { CLIOptions } from "./types";
@@ -205,22 +205,6 @@ const protocolFixtures: ProtocolFixture[] = [
expect(settings.P2P_AppID).toBe("self-hosted-livesync");
},
},
{
protocol: "webdav",
connectionString: ConnectionStringParser.serialize({
type: "webdav",
settings: {
webDAVactiveConnectionURI:
"sls+webdav://user:pass@webdav.example.com/dav?prefix=vault%2F&headers=x-test%3A1&useProxy=true",
},
}),
assertProjectedFields: (settings) => {
expect(settings.remoteType).toBe(REMOTE_WEBDAV);
expect(settings.webDAVactiveConnectionURI).toBe(
"sls+webdav://user:pass@webdav.example.com/dav?prefix=vault%2F&headers=x-test%3A1&useProxy=true"
);
},
},
];
describe("runCommand abnormal cases", () => {
@@ -580,10 +564,6 @@ describe("runCommand abnormal cases", () => {
"p2p",
"sls+p2p://room-abc?passphrase=pass-123&relays=wss%3A%2F%2Frelay.example&appId=self-hosted-livesync",
] as const,
[
"webdav",
"sls+webdav://user:pass@webdav.example.com/dav?prefix=vault%2F&headers=x-test%3A1&useProxy=true",
] as const,
])("remote command round-trip works for %s", async (_protocol, initialConnStr) => {
const core = createCoreMock();
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "0.25.79-cli",
"version": "0.25.80-cli",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "0.25.79-webapp",
"version": "0.25.80-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "0.25.79-webpeer",
"version": "0.25.80-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
@@ -51,6 +51,7 @@ import { EVENT_SETTING_SAVED, eventHub } from "@/common/events.ts";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import type { LiveSyncCore } from "@/main.ts";
import { tryGetFilePath } from "@lib/common/utils.doc.ts";
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
type SyncDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce";
declare global {
@@ -1832,43 +1833,35 @@ ${messageFetch}${messageOverwrite}${messageMerge}
}
async configureHiddenFileSync(mode: keyof OPTIONAL_SYNC_FEATURES) {
if (
mode != "FETCH" &&
mode != "OVERWRITE" &&
mode != "MERGE" &&
mode != "DISABLE" &&
mode != "DISABLE_HIDDEN"
) {
return;
}
if (mode == "DISABLE" || mode == "DISABLE_HIDDEN") {
// await this.core.$allSuspendExtraSync();
await this.core.services.setting.applyPartial(
{
syncInternalFiles: false,
},
true
);
// this.core.settings.syncInternalFiles = false;
// await this.core.saveSettings();
return;
}
this._log("Gathering files for enabling Hidden File Sync", LOG_LEVEL_NOTICE);
if (mode == "FETCH") {
await this.initialiseInternalFileSync("pullForce", true);
} else if (mode == "OVERWRITE") {
await this.initialiseInternalFileSync("pushForce", true);
} else if (mode == "MERGE") {
await this.initialiseInternalFileSync("safe", true);
}
await this.core.services.setting.applyPartial(
{
useAdvancedMode: true,
syncInternalFiles: true,
const result = await configureHiddenFileSyncMode(mode, {
disable: async () => {
// await this.core.$allSuspendExtraSync();
await this.core.services.setting.applyPartial(
{
syncInternalFiles: false,
},
true
);
// this.core.settings.syncInternalFiles = false;
// await this.core.saveSettings();
},
true
);
enable: async () => {
this._log("Gathering files for enabling Hidden File Sync", LOG_LEVEL_NOTICE);
await this.core.services.setting.applyPartial(
{
useAdvancedMode: true,
syncInternalFiles: true,
},
true
);
},
initialise: async (direction) => {
await this.initialiseInternalFileSync(direction, true);
},
});
if (result == "ignored" || result == "disabled") {
return;
}
// this.plugin.settings.useAdvancedMode = true;
// this.plugin.settings.syncInternalFiles = true;
@@ -0,0 +1,45 @@
type HiddenFileSyncMode = "FETCH" | "OVERWRITE" | "MERGE" | "DISABLE" | "DISABLE_HIDDEN";
type HiddenFileSyncDirection = "pullForce" | "pushForce" | "safe";
type ConfigureHiddenFileSyncHandlers = {
disable: () => Promise<void>;
enable: () => Promise<void>;
initialise: (direction: HiddenFileSyncDirection) => Promise<void>;
};
export type ConfigureHiddenFileSyncResult = "ignored" | "disabled" | "enabled";
function getInitialiseDirection(mode: keyof OPTIONAL_SYNC_FEATURES): HiddenFileSyncDirection | false {
if (mode == "FETCH") return "pullForce";
if (mode == "OVERWRITE") return "pushForce";
if (mode == "MERGE") return "safe";
return false;
}
function isDisableMode(mode: keyof OPTIONAL_SYNC_FEATURES): boolean {
return mode == "DISABLE" || mode == "DISABLE_HIDDEN";
}
function isHiddenFileSyncMode(mode: keyof OPTIONAL_SYNC_FEATURES): mode is HiddenFileSyncMode {
return mode == "FETCH" || mode == "OVERWRITE" || mode == "MERGE" || isDisableMode(mode);
}
export async function configureHiddenFileSyncMode(
mode: keyof OPTIONAL_SYNC_FEATURES,
handlers: ConfigureHiddenFileSyncHandlers
): Promise<ConfigureHiddenFileSyncResult> {
if (!isHiddenFileSyncMode(mode)) {
return "ignored";
}
if (isDisableMode(mode)) {
await handlers.disable();
return "disabled";
}
const direction = getInitialiseDirection(mode);
if (direction === false) {
return "ignored";
}
await handlers.enable();
await handlers.initialise(direction);
return "enabled";
}
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
describe("configureHiddenFileSyncMode", () => {
it.each([
["FETCH", "pullForce"],
["OVERWRITE", "pushForce"],
["MERGE", "safe"],
] as const)("enables hidden file sync before initialising %s", async (mode, direction) => {
const calls: string[] = [];
const result = await configureHiddenFileSyncMode(mode, {
disable: vi.fn(async () => {
calls.push("disable");
}),
enable: vi.fn(async () => {
calls.push("enable");
}),
initialise: vi.fn(async (actualDirection) => {
calls.push(`init:${actualDirection}`);
}),
});
expect(result).toBe("enabled");
expect(calls).toEqual(["enable", `init:${direction}`]);
});
it.each(["DISABLE", "DISABLE_HIDDEN"] as const)("disables hidden file sync immediately for %s", async (mode) => {
const calls: string[] = [];
const result = await configureHiddenFileSyncMode(mode, {
disable: vi.fn(async () => {
calls.push("disable");
}),
enable: vi.fn(async () => {
calls.push("enable");
}),
initialise: vi.fn(async (direction) => {
calls.push(`init:${direction}`);
}),
});
expect(result).toBe("disabled");
expect(calls).toEqual(["disable"]);
});
});
+1 -1
Submodule src/lib updated: 2ee5d2055a...a0efb7274e
+3 -3
View File
@@ -1,5 +1,5 @@
import { fireAndForget } from "octagonal-wheels/promises";
import { REMOTE_P2P, isJournalRemoteType, type RemoteDBSettings } from "@lib/common/types";
import { REMOTE_MINIO, REMOTE_P2P, type RemoteDBSettings } from "@lib/common/types";
import { LiveSyncCouchDBReplicator } from "@lib/replication/couchdb/LiveSyncReplicator";
import type { LiveSyncAbstractReplicator } from "@lib/replication/LiveSyncAbstractReplicator";
import { AbstractModule } from "@/modules/AbstractModule";
@@ -9,7 +9,7 @@ export class ModuleReplicatorCouchDB extends AbstractModule {
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
const settings = { ...this.settings, ...settingOverride };
// If new remote types were added, add them here. Do not use `REMOTE_COUCHDB` directly for the safety valve.
if (isJournalRemoteType(settings.remoteType) || settings.remoteType == REMOTE_P2P) {
if (settings.remoteType == REMOTE_MINIO || settings.remoteType == REMOTE_P2P) {
return Promise.resolve(false);
}
return Promise.resolve(new LiveSyncCouchDBReplicator(this.core));
@@ -17,7 +17,7 @@ export class ModuleReplicatorCouchDB extends AbstractModule {
_everyAfterResumeProcess(): Promise<boolean> {
if (this.services.appLifecycle.isSuspended()) return Promise.resolve(true);
if (!this.services.appLifecycle.isReady()) return Promise.resolve(true);
if (!isJournalRemoteType(this.settings.remoteType) && this.settings.remoteType != REMOTE_P2P) {
if (this.settings.remoteType != REMOTE_MINIO && this.settings.remoteType != REMOTE_P2P) {
const LiveSyncEnabled = this.settings.liveSync;
const continuous = LiveSyncEnabled;
const eventualOnStart = !LiveSyncEnabled && this.settings.syncOnStart;
+2 -2
View File
@@ -1,4 +1,4 @@
import { isJournalRemoteType, type RemoteDBSettings } from "@lib/common/types";
import { REMOTE_MINIO, type RemoteDBSettings } from "@lib/common/types";
import { LiveSyncJournalReplicator } from "@lib/replication/journal/LiveSyncJournalReplicator";
import type { LiveSyncAbstractReplicator } from "@lib/replication/LiveSyncAbstractReplicator";
import type { LiveSyncCore } from "@/main";
@@ -7,7 +7,7 @@ import { AbstractModule } from "@/modules/AbstractModule";
export class ModuleReplicatorMinIO extends AbstractModule {
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
const settings = { ...this.settings, ...settingOverride };
if (isJournalRemoteType(settings.remoteType)) {
if (settings.remoteType == REMOTE_MINIO) {
return Promise.resolve(new LiveSyncJournalReplicator(this.core));
}
return Promise.resolve(false);
@@ -12,7 +12,6 @@ import {
LEVEL_ADVANCED,
LEVEL_EDGE_CASE,
REMOTE_P2P,
isJournalRemoteType,
} from "@lib/common/types.ts";
import { delay, isObjectDifferent, sizeToHumanReadable } from "@lib/common/utils.ts";
import { versionNumberString2Number } from "@lib/string_and_binary/convert.ts";
@@ -65,7 +64,7 @@ import { panePatches } from "./PanePatches.ts";
import { paneMaintenance } from "./PaneMaintenance.ts";
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
import { JournalSyncCore } from "@lib/replication/journal/JournalSyncCore.js";
import { createJournalStorageAdapter } from "@lib/replication/journal/objectstore/JournalStorageAdapterFactory.js";
import { MinioStorageAdapter } from "@lib/replication/journal/objectstore/MinioStorageAdapter.js";
// For creating a document
// const toc = new Set<string>();
@@ -531,10 +530,6 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
({
visibility: this.isConfiguredAs("remoteType", REMOTE_MINIO),
}) as OnUpdateResult;
onlyOnJournalSync = () =>
({
visibility: isJournalRemoteType(this.editingSettings.remoteType),
}) as OnUpdateResult;
onlyOnOnlyP2P = () =>
({
visibility: this.isConfiguredAs("remoteType", REMOTE_P2P),
@@ -542,14 +537,11 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
onlyOnCouchDBOrMinIO = () =>
({
visibility:
this.isConfiguredAs("remoteType", REMOTE_COUCHDB) ||
isJournalRemoteType(this.editingSettings.remoteType),
this.isConfiguredAs("remoteType", REMOTE_COUCHDB) || this.isConfiguredAs("remoteType", REMOTE_MINIO),
}) as OnUpdateResult;
// E2EE Function
checkWorkingPassphrase = async (): Promise<boolean> => {
if (isJournalRemoteType(this.editingSettings.remoteType)) {
return true;
}
if (this.editingSettings.remoteType == REMOTE_MINIO) return true;
const settingForCheck: RemoteDBSettings = {
...this.editingSettings,
@@ -864,7 +856,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
this.core.settings,
this.core.simpleStore,
this.core,
createJournalStorageAdapter(this.core.settings, this.core)
new MinioStorageAdapter(this.core.settings, this.core)
);
}
async resetRemoteBucket() {
@@ -163,7 +163,7 @@ export function paneMaintenance(
Logger(`Journal received history has been cleared.`, LOG_LEVEL_NOTICE);
})
)
.addOnUpdate(this.onlyOnJournalSync);
.addOnUpdate(this.onlyOnMinIO);
new Setting(paneEl)
.setName("Reset journal sent history")
@@ -185,7 +185,7 @@ export function paneMaintenance(
Logger(`Journal sent history has been cleared.`, LOG_LEVEL_NOTICE);
})
)
.addOnUpdate(this.onlyOnJournalSync);
.addOnUpdate(this.onlyOnMinIO);
});
void addPanel(paneEl, "Garbage Collection V3 (Beta)", (e) => e, this.onlyOnP2POrCouchDB).then((paneEl) => {
new Setting(paneEl)
@@ -336,7 +336,7 @@ export function paneMaintenance(
Logger(`Journal exchange history has been cleared.`, LOG_LEVEL_NOTICE);
})
)
.addOnUpdate(this.onlyOnJournalSync);
.addOnUpdate(this.onlyOnMinIO);
new Setting(paneEl)
.setName("Purge all journal counter")
@@ -351,7 +351,7 @@ export function paneMaintenance(
Logger(`Journal download/upload cache has been cleared.`, LOG_LEVEL_NOTICE);
})
)
.addOnUpdate(this.onlyOnJournalSync);
.addOnUpdate(this.onlyOnMinIO);
new Setting(paneEl)
.setName("Fresh Start Wipe")
@@ -374,7 +374,7 @@ export function paneMaintenance(
Logger(`Deleted all data on remote server`, LOG_LEVEL_NOTICE);
})
)
.addOnUpdate(this.onlyOnJournalSync);
.addOnUpdate(this.onlyOnMinIO);
});
void addPanel(paneEl, "Reset").then((paneEl) => {
@@ -2,7 +2,6 @@ import {
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
REMOTE_WEBDAV,
DEFAULT_SETTINGS,
LOG_LEVEL_NOTICE,
type ObsidianLiveSyncSettings,
@@ -34,9 +33,7 @@ import SetupRemote from "@/modules/features/SetupWizard/dialogs/SetupRemote.svel
import SetupRemoteCouchDB from "@/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
import SetupRemoteBucket from "@/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte";
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
import SetupRemoteWebDAV from "@/modules/features/SetupWizard/dialogs/SetupRemoteWebDAV.svelte";
import { syncActivatedRemoteSettings } from "./remoteConfigBuffer.ts";
import { getRemoteConfigurationDescription } from "./remoteConfigDisplay.ts";
function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianLiveSyncSettings {
const workObj = { ...editingSettings } as ObsidianLiveSyncSettings;
@@ -72,9 +69,6 @@ function serializeRemoteConfiguration(settings: ObsidianLiveSyncSettings): strin
if (settings.remoteType === REMOTE_P2P) {
return ConnectionStringParser.serialize({ type: "p2p", settings });
}
if (settings.remoteType === REMOTE_WEBDAV) {
return ConnectionStringParser.serialize({ type: "webdav", settings });
}
return ConnectionStringParser.serialize({ type: "couchdb", settings });
}
@@ -98,14 +92,6 @@ function suggestRemoteConfigurationName(parsed: RemoteConfigurationResult): stri
if (parsed.type === "s3") {
return `S3 ${parsed.settings.bucket || parsed.settings.endpoint}`;
}
if (parsed.type === "webdav") {
try {
const url = new URL(parsed.settings.webDAVactiveConnectionURI.replace(/^sls\+webdav:/, "https:"));
return `WebDAV ${url.host}`;
} catch {
return "Imported WebDAV";
}
}
return `P2P ${parsed.settings.P2P_roomID || "Remote"}`;
}
@@ -212,7 +198,7 @@ export function paneRemoteConfig(
};
const runRemoteSetup = async (
baseSettings: ObsidianLiveSyncSettings,
remoteType?: typeof REMOTE_COUCHDB | typeof REMOTE_MINIO | typeof REMOTE_P2P | typeof REMOTE_WEBDAV
remoteType?: typeof REMOTE_COUCHDB | typeof REMOTE_MINIO | typeof REMOTE_P2P
): Promise<ObsidianLiveSyncSettings | false> => {
const setupManager = this.core.getModule(SetupManager);
const dialogManager = setupManager.dialogManager;
@@ -224,13 +210,7 @@ export function paneRemoteConfig(
return false;
}
targetRemoteType =
method === "bucket"
? REMOTE_MINIO
: method === "p2p"
? REMOTE_P2P
: method === "webdav"
? REMOTE_WEBDAV
: REMOTE_COUCHDB;
method === "bucket" ? REMOTE_MINIO : method === "p2p" ? REMOTE_P2P : REMOTE_COUCHDB;
}
if (targetRemoteType === REMOTE_MINIO) {
@@ -249,14 +229,6 @@ export function paneRemoteConfig(
return { ...baseSettings, ...p2pConf, remoteType: REMOTE_P2P };
}
if (targetRemoteType === REMOTE_WEBDAV) {
const webDAVConf = await dialogManager.openWithExplicitCancel(SetupRemoteWebDAV, baseSettings);
if (webDAVConf === "cancelled" || typeof webDAVConf !== "object") {
return false;
}
return { ...baseSettings, ...webDAVConf, remoteType: REMOTE_WEBDAV };
}
const couchConf = await dialogManager.openWithExplicitCancel(SetupRemoteCouchDB, baseSettings);
if (couchConf === "cancelled" || typeof couchConf !== "object") {
return false;
@@ -359,7 +331,7 @@ export function paneRemoteConfig(
for (const config of Object.values(configs)) {
const row = new Setting(listContainer)
.setName(config.name)
.setDesc(getRemoteConfigurationDescription(config.uri));
.setDesc(config.uri.split("@").pop() || ""); // Show host part for privacy
if (config.id === this.editingSettings.activeConfigurationId) {
row.nameEl.addClass("sls-active-remote-name");
@@ -384,8 +356,6 @@ export function paneRemoteConfig(
workSettings.remoteType = REMOTE_COUCHDB;
} else if (parsed.type === "s3") {
workSettings.remoteType = REMOTE_MINIO;
} else if (parsed.type === "webdav") {
workSettings.remoteType = REMOTE_WEBDAV;
} else {
workSettings.remoteType = REMOTE_P2P;
}
@@ -496,8 +466,6 @@ export function paneRemoteConfig(
workSettings.remoteType = REMOTE_COUCHDB;
} else if (parsed.type === "s3") {
workSettings.remoteType = REMOTE_MINIO;
} else if (parsed.type === "webdav") {
workSettings.remoteType = REMOTE_WEBDAV;
} else {
workSettings.remoteType = REMOTE_P2P;
}
@@ -1,12 +0,0 @@
import { describe, expect, it } from "vitest";
import { getRemoteConfigurationDescription } from "./remoteConfigDisplay";
describe("getRemoteConfigurationDescription", () => {
it("should mask credentials and query parameters in WebDAV connection strings", () => {
expect(
getRemoteConfigurationDescription(
"sls+webdav://user:pass@example.com/dav?prefix=vault%2F&headers=Authorization%3A%20Bearer%20token"
)
).toBe("https://example.com/dav");
});
});
@@ -1,9 +1,4 @@
import {
pickBucketSyncSettings,
pickCouchDBSyncSettings,
pickP2PSyncSettings,
pickWebDAVSyncSettings,
} from "@lib/common/utils.ts";
import { pickBucketSyncSettings, pickCouchDBSyncSettings, pickP2PSyncSettings } from "@lib/common/utils.ts";
import type { ObsidianLiveSyncSettings } from "@lib/common/types.ts";
// Keep the setting dialogue buffer aligned with the current core settings before persisting other dirty keys.
@@ -18,6 +13,5 @@ export function syncActivatedRemoteSettings(
...pickBucketSyncSettings(source),
...pickCouchDBSyncSettings(source),
...pickP2PSyncSettings(source),
...pickWebDAVSyncSettings(source),
});
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_WEBDAV } from "@lib/common/types";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO } from "@lib/common/types";
import { syncActivatedRemoteSettings } from "./remoteConfigBuffer";
describe("syncActivatedRemoteSettings", () => {
@@ -80,25 +80,4 @@ describe("syncActivatedRemoteSettings", () => {
expect(target.couchDB_PASSWORD).toBe("current-pass");
expect(target.couchDB_DBNAME).toBe("current-db");
});
it("should copy active WebDAV connection URI into the editing buffer", () => {
const target = {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
activeConfigurationId: "old-remote",
webDAVactiveConnectionURI: "",
};
const source = {
...DEFAULT_SETTINGS,
remoteType: REMOTE_WEBDAV,
activeConfigurationId: "remote-webdav",
webDAVactiveConnectionURI: "sls+webdav://user:pass@example.com/dav?prefix=vault%2F",
};
syncActivatedRemoteSettings(target, source);
expect(target.remoteType).toBe(REMOTE_WEBDAV);
expect(target.activeConfigurationId).toBe("remote-webdav");
expect(target.webDAVactiveConnectionURI).toBe("sls+webdav://user:pass@example.com/dav?prefix=vault%2F");
});
});
@@ -1,29 +0,0 @@
import { ConnectionStringParser } from "@lib/common/ConnectionString.ts";
export function getRemoteConfigurationDescription(uri: string): string {
try {
const parsed = ConnectionStringParser.parse(uri);
if (parsed.type === "couchdb") {
const url = new URL(parsed.settings.couchDB_URI);
url.username = "";
url.password = "";
url.search = "";
url.hash = "";
return url.toString();
}
if (parsed.type === "s3") {
return `${parsed.settings.endpoint.replace(/\/+$/g, "")}/${parsed.settings.bucket}`;
}
if (parsed.type === "webdav") {
const url = new URL(parsed.settings.webDAVactiveConnectionURI.replace(/^sls\+webdav:/, "https:"));
url.username = "";
url.password = "";
url.search = "";
url.hash = "";
return url.toString();
}
return `P2P ${parsed.settings.P2P_roomID || "Remote"}`;
} catch {
return uri.split("@").pop()?.split("?")[0] || "";
}
}
-33
View File
@@ -4,14 +4,12 @@ import {
type EncryptionSettings,
type ObsidianLiveSyncSettings,
type P2PSyncSetting,
type WebDAVSyncSetting,
DEFAULT_SETTINGS,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
REMOTE_WEBDAV,
} from "@lib/common/types.ts";
import { isObjectDifferent } from "@lib/common/utils.ts";
import Intro from "./SetupWizard/dialogs/Intro.svelte";
@@ -26,7 +24,6 @@ import SetupRemote from "./SetupWizard/dialogs/SetupRemote.svelte";
import SetupRemoteCouchDB from "./SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
import SetupRemoteBucket from "./SetupWizard/dialogs/SetupRemoteBucket.svelte";
import SetupRemoteP2P from "./SetupWizard/dialogs/SetupRemoteP2P.svelte";
import SetupRemoteWebDAV from "./SetupWizard/dialogs/SetupRemoteWebDAV.svelte";
import SetupRemoteE2EE from "./SetupWizard/dialogs/SetupRemoteE2EE.svelte";
import { decodeSettingsFromQRCodeData } from "@lib/API/processSetting.ts";
import { AbstractModule } from "@/modules/AbstractModule.ts";
@@ -41,7 +38,6 @@ import type {
SetupRemoteE2EEResultType,
SetupRemoteP2PResultType,
SetupRemoteResultType,
SetupRemoteWebDAVResultType,
UseSetupURIResultType,
} from "./SetupWizard/dialogs/setupDialogTypes.ts";
@@ -206,33 +202,6 @@ export class SetupManager extends AbstractModule {
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
}
/**
* Handles manual setup for WebDAV-compatible storage.
* @param userMode
* @param currentSetting
* @param activate Whether to activate WebDAV as remote type
* @returns Promise that resolves to true if setup completed successfully, false otherwise
*/
async onWebDAVManualSetup(
userMode: UserMode,
currentSetting: ObsidianLiveSyncSettings,
activate = true
): Promise<boolean> {
const webDAVConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteWebDAVResultType,
WebDAVSyncSetting
>(SetupRemoteWebDAV, currentSetting);
if (webDAVConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const newSetting = { ...currentSetting, ...webDAVConf } as ObsidianLiveSyncSettings;
if (activate) {
newSetting.remoteType = REMOTE_WEBDAV;
}
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
}
/**
* Handles manual setup for P2P
* @param userMode
@@ -334,8 +303,6 @@ export class SetupManager extends AbstractModule {
return await this.onBucketManualSetup(userMode, currentSetting, true);
} else if (method === "p2p") {
return await this.onP2PManualSetup(userMode, currentSetting, true);
} else if (method === "webdav") {
return await this.onWebDAVManualSetup(userMode, currentSetting, true);
} else if (method === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
if (userMode !== UserMode.Unknown) {
@@ -10,7 +10,6 @@
TYPE_COUCHDB,
TYPE_BUCKET,
TYPE_P2P,
TYPE_WEBDAV,
TYPE_CANCELLED,
type SetupRemoteResultType,
} from "./setupDialogTypes";
@@ -27,19 +26,12 @@
return "Continue to S3/MinIO/R2 setup";
} else if (userType === TYPE_P2P) {
return "Continue to Peer-to-Peer only setup";
} else if (userType === TYPE_WEBDAV) {
return "Continue to WebDAV setup";
} else {
return "Please select an option to proceed";
}
});
const canProceed = $derived.by(() => {
return (
userType === TYPE_COUCHDB ||
userType === TYPE_BUCKET ||
userType === TYPE_P2P ||
userType === TYPE_WEBDAV
);
return userType === TYPE_COUCHDB || userType === TYPE_BUCKET || userType === TYPE_P2P;
});
</script>
@@ -54,10 +46,6 @@
<Option selectedValue={TYPE_BUCKET} title="S3/MinIO/R2 Object Storage" bind:value={userType}>
Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.
</Option>
<Option selectedValue={TYPE_WEBDAV} title="WebDAV (Experimental)" bind:value={userType}>
Experimental synchronisation utilising journal files stored in a WebDAV collection. This is a proof of
concept for pluggable Journal storage backends.
</Option>
<Option selectedValue={TYPE_P2P} title="Peer-to-Peer only" bind:value={userType}>
This feature enables direct synchronisation between devices. No server is required, but both devices must be
online at the same time for synchronisation to occur, and some features may be limited. Internet connection
@@ -1,160 +0,0 @@
<script lang="ts">
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
import Guidance from "@lib/UI/components/Guidance.svelte";
import Decision from "@lib/UI/components/Decision.svelte";
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
import InfoNote from "@lib/UI/components/InfoNote.svelte";
import InputRow from "@lib/UI/components/InputRow.svelte";
import {
DEFAULT_SETTINGS,
PREFERRED_JOURNAL_SYNC,
RemoteTypes,
type ObsidianLiveSyncSettings,
type WebDAVSyncSetting,
} from "@lib/common/types";
import { copyTo, pickWebDAVSyncSettings } from "@lib/common/utils";
import { getDialogContext, type GuestDialogProps } from "@lib/UI/svelteDialog";
import { onMount } from "svelte";
import { TYPE_CANCELLED, type SetupRemoteWebDAVResultType } from "./setupDialogTypes";
const default_setting = pickWebDAVSyncSettings(DEFAULT_SETTINGS);
let syncSetting = $state<WebDAVSyncSetting>({ ...default_setting });
type Props = GuestDialogProps<SetupRemoteWebDAVResultType, WebDAVSyncSetting>;
const { setResult, getInitialData }: Props = $props();
onMount(() => {
if (getInitialData) {
const initialData = getInitialData();
if (initialData) {
copyTo(initialData, syncSetting);
}
}
});
let error = $state("");
let processing = $state(false);
const context = getDialogContext();
function parseURI(): URL | false {
const match = syncSetting.webDAVactiveConnectionURI.trim().match(/^sls\+webdav:(.*)$/);
if (!match) {
return false;
}
try {
return new URL(`https:${match[1]}`);
} catch {
return false;
}
}
const parsedURI = $derived.by(() => parseURI());
const canProceed = $derived.by(() => parsedURI !== false);
const isInsecure = $derived.by(() => parsedURI !== false && parsedURI.searchParams.get("insecure") === "true");
const usesInternalAPI = $derived.by(
() => parsedURI !== false && parsedURI.searchParams.get("useProxy") === "true"
);
function generateSetting() {
const trialSettings: WebDAVSyncSetting = {
...syncSetting,
webDAVactiveConnectionURI: syncSetting.webDAVactiveConnectionURI.trim(),
};
const trialRemoteSetting: ObsidianLiveSyncSettings = {
...DEFAULT_SETTINGS,
...PREFERRED_JOURNAL_SYNC,
remoteType: RemoteTypes.REMOTE_WEBDAV,
...trialSettings,
};
return trialRemoteSetting;
}
async function checkConnection() {
try {
processing = true;
const trialRemoteSetting = generateSetting();
const replicator = await context.services.replicator.getNewReplicator(trialRemoteSetting);
if (!replicator) {
return "Failed to create replicator instance.";
}
try {
const result = await replicator.tryConnectRemote(trialRemoteSetting, false);
if (result) {
return "";
}
return "Failed to connect to the server. Please check your settings.";
} catch (e) {
return `Failed to connect to the server: ${e}`;
}
} finally {
processing = false;
}
}
async function checkAndCommit() {
error = "";
try {
error = (await checkConnection()) || "";
if (!error) {
setResult(pickWebDAVSyncSettings(generateSetting()));
return;
}
} catch (e) {
error = `Error during connection test: ${e}`;
}
}
function commit() {
setResult(pickWebDAVSyncSettings(generateSetting()));
}
function cancel() {
setResult(TYPE_CANCELLED);
}
</script>
<DialogHeader title="WebDAV Configuration" />
<Guidance>
Please enter the WebDAV connection URI. This experimental setup stores Journal synchronisation files in a WebDAV
collection.
</Guidance>
<InputRow label="Connection URI">
<input
type="text"
name="webdav-connection-uri"
placeholder="sls+webdav://user:password@example.com/dav?prefix=vault%2F"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
required
bind:value={syncSetting.webDAVactiveConnectionURI}
/>
</InputRow>
<InfoNote>
Use `sls+webdav://user:password@host/path`. Optional query parameters include `prefix`, `useProxy=true`, and
`insecure=true` for local HTTP testing.
</InfoNote>
<InfoNote warning visible={isInsecure}>Secure HTTPS connections are required on Obsidian Mobile.</InfoNote>
<InfoNote visible={usesInternalAPI}>
This connection uses Obsidian's internal API. It can help when direct WebDAV requests are blocked by CORS.
</InfoNote>
<InfoNote error visible={syncSetting.webDAVactiveConnectionURI.trim() !== "" && !canProceed}>
The connection URI must start with `sls+webdav://`.
</InfoNote>
<InfoNote error visible={error !== ""}>
{error}
</InfoNote>
{#if processing}
Checking connection... Please wait.
{:else}
<UserDecisions>
<Decision title="Test Settings and Continue" important disabled={!canProceed} commit={() => checkAndCommit()} />
<Decision title="Continue anyway" disabled={!canProceed} commit={() => commit()} />
<Decision title="Cancel" commit={() => cancel()} />
</UserDecisions>
{/if}
@@ -4,7 +4,6 @@ import type {
EncryptionSettings,
ObsidianLiveSyncSettings,
P2PConnectionInfo,
WebDAVSyncSetting,
} from "@lib/common/models/setting.type";
export const TYPE_IDENTICAL = "identical";
@@ -41,7 +40,6 @@ export const TYPE_CLOSE = "close";
export const TYPE_COUCHDB = "couchdb";
export const TYPE_BUCKET = "bucket";
export const TYPE_P2P = "p2p";
export const TYPE_WEBDAV = "webdav";
export type ResultTypeVault =
| typeof TYPE_IDENTICAL
@@ -95,12 +93,7 @@ export type SelectMethodExistingResultType =
| typeof TYPE_CONFIGURE_MANUALLY
| typeof TYPE_CANCELLED;
export type SetupRemoteResultType =
| typeof TYPE_COUCHDB
| typeof TYPE_BUCKET
| typeof TYPE_P2P
| typeof TYPE_WEBDAV
| typeof TYPE_CANCELLED;
export type SetupRemoteResultType = typeof TYPE_COUCHDB | typeof TYPE_BUCKET | typeof TYPE_P2P | typeof TYPE_CANCELLED;
export type UseSetupURIResultType = typeof TYPE_CANCELLED | ObsidianLiveSyncSettings;
@@ -112,6 +105,4 @@ export type SetupRemoteCouchDBResultType = typeof TYPE_CANCELLED | CouchDBConnec
export type SetupRemoteP2PResultType = typeof TYPE_CANCELLED | P2PConnectionInfo;
export type SetupRemoteWebDAVResultType = typeof TYPE_CANCELLED | WebDAVSyncSetting;
export type ScanQRCodeResultType = typeof TYPE_CLOSE;
+7 -5
View File
@@ -147,12 +147,14 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
: req instanceof Request && typeof req.method === "string"
? req.method
: "GET";
if (opts?.body) {
body = typeof opts.body === "string" ? opts.body : await new Response(opts.body).arrayBuffer();
} else if (typeof req !== "string" && req.body) {
body = await new Response(req.body).arrayBuffer();
if (typeof req !== "string") {
if (opts?.body) {
body = typeof opts.body === "string" ? opts.body : await new Response(opts.body).arrayBuffer();
} else if (req.body) {
body = await new Response(req.body).arrayBuffer();
}
} else {
body = undefined;
body = opts?.body as string;
}
const reqHeaders = new Headers(req instanceof Request ? req.headers : {});
@@ -1,63 +0,0 @@
import { describe, expect, it, vi } from "vitest";
const requestUrlMock = vi.hoisted(() => vi.fn());
vi.mock("@/deps", () => {
return {
requestUrl: requestUrlMock,
Notice: vi.fn(),
Platform: {},
};
});
vi.mock("@/deps.ts", () => {
return {
requestUrl: requestUrlMock,
Notice: vi.fn(),
Platform: {},
};
});
vi.mock("./ObsidianConfirm", () => ({
ObsidianConfirm: class {},
}));
import { ObsidianAPIService } from "./ObsidianAPIService.ts";
import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext.ts";
describe("ObsidianAPIService.nativeFetch", () => {
it("should pass binary string-request bodies to requestUrl as ArrayBuffer", async () => {
requestUrlMock.mockResolvedValueOnce({
arrayBuffer: new TextEncoder().encode("ok").buffer,
headers: {},
status: 207,
});
const service = new ObsidianAPIService({} as ObsidianServiceContext);
const body = new TextEncoder().encode("payload");
const response = await service.nativeFetch("https://webdav.example.com/file", {
method: "PUT",
body: body as unknown as BodyInit,
headers: {
Depth: "1",
Authorization: "Basic dXNlcjpwYXNz",
},
});
expect(response.status).toBe(207);
expect(requestUrlMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://webdav.example.com/file",
method: "PUT",
body: expect.any(ArrayBuffer),
headers: expect.objectContaining({
Depth: "1",
Authorization: "Basic dXNlcjpwYXNz",
}),
throw: false,
})
);
const requestBody = requestUrlMock.mock.calls[0][0].body as ArrayBuffer;
expect(new TextDecoder().decode(requestBody)).toBe("payload");
});
});
-4
View File
@@ -1,4 +0,0 @@
#!/bin/bash
set -e
docker inspect webdav-test >/dev/null
-15
View File
@@ -1,15 +0,0 @@
#!/bin/bash
set -e
webdavUsername=${webdavUsername:-webdav}
webdavPassword=${webdavPassword:-webdav}
webdavPort=${webdavPort:-8088}
docker run -d \
--name webdav-test \
-p "$webdavPort:8080" \
rclone/rclone serve webdav /data \
--addr :8080 \
--baseurl /dav \
--user "$webdavUsername" \
--pass "$webdavPassword"
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
docker stop webdav-test
docker rm webdav-test
+13 -40
View File
@@ -3,12 +3,21 @@ 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
## 0.25.80
### Experimental
7th July, 2026
- Added experimental WebDAV support for Journal synchronisation storage, including setup dialogue integration. This is currently a proof of concept for injecting Journal storage backends through the replication layer, and should be treated as an implementation experiment rather than a fully supported setup flow.
- Hardened the WebDAV Journal storage proof of concept by centralising Journal storage adapter selection, improving connection checks, masking remote configuration descriptions, and verifying the Docker WebDAV integration path.
### Fixed
- Improved Markdown conflict auto-merge so that non-overlapping edits are merged while overlapping delete-and-edit cases remain visible for manual resolution (#993).
- Behaviour change:
- When one side deletes an unchanged line and the other side edits a different region, the deleted line is no longer reintroduced into the merged result.
- When one side deletes a line and the other side modifies that same line, the conflict is preserved instead of silently choosing one side.
- Fixed an issue where applying a newer database entry to storage could incorrectly preserve an older local file as a conflict (#994).
- Behaviour change:
- Local storage is preserved as a conflict when it may contain unsynchronised changes that are not represented in the revision history. A newer incoming text entry is applied without creating a conflict only when it clearly extends the existing local text.
- Fixed an issue where choosing Disable and then Overwrite in Hidden File Sync could silently skip hidden files, because the overwrite setup ran while hidden file synchronisation was still disabled (#989, PR #992).
- Hidden File Sync is now re-enabled before the Fetch, Overwrite, or Merge initialisation runs, instead of after it completes. If that initialisation fails, the setting may remain enabled.
## 0.25.79
@@ -103,41 +112,5 @@ Also, this update is a very large one, even if we had a lot of time, and we had
- Some dependencies have been updated.
- Now we check the compatibility with iOS 15 in the CI tests to ensure the plugin continues to work on older iOS versions even after we upgrade some dependencies.
## 0.25.74
8th June, 2026
### Fixed
- Fixed an issue where disabling hidden file synchronisation did not take effect, allowing non-target hidden files to continue to be processed and synchronised by replication or boot-sequence scan (#941).
- Prevented the automatic merging of conflicted revisions when one of the revisions has been deleted, which was causing deleted files to reappear (#911).
- The startup sequence now saves the state more effectively (Thank you so much for @bmcyver)!
## Only CLI
8th June, 2026
I should also consider the version numbering for the CLI...
### Improved
- Added new remote database management commands: `remote-status`, `unlock-remote`, `lock-remote`, and `mark-resolved`.
- --vault option is now available for daemon and mirror commands! (Thank you so much for @starskyzheng)!
- Decoupled the database directory path from the actual vault directory path using the `--vault` (or `-V`) option.
### Fixed (preventive)
- Validated that the specified vault path exists and is indeed a directory before starting the CLI.
- Integrated path resolution and validations for one-off commands (such as `'push'`, `'pull'`, `'cat'`, `'rm'`, `'info'`, and `'resolve'`) against the decoupled vault path instead of the database path.
## 0.25.73
4th June, 2026
### Fixed
- Adjust CouchDB's database name checking to its specification (#926).
- `Reset Syncronisation on This Device` for minio and P2P is now working properly.
Full notes are in
[updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md).
+58
View File
@@ -4,6 +4,64 @@ 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.
## 0.25.80
7th July, 2026
### Fixed
- Improved Markdown conflict auto-merge so that non-overlapping edits are merged while overlapping delete-and-edit cases remain visible for manual resolution (#993).
- Behaviour change:
- When one side deletes an unchanged line and the other side edits a different region, the deleted line is no longer reintroduced into the merged result.
- When one side deletes a line and the other side modifies that same line, the conflict is preserved instead of silently choosing one side.
- Fixed an issue where applying a newer database entry to storage could incorrectly preserve an older local file as a conflict (#994).
- Behaviour change:
- Local storage is preserved as a conflict when it may contain unsynchronised changes that are not represented in the revision history. A newer incoming text entry is applied without creating a conflict only when it clearly extends the existing local text.
- Fixed an issue where choosing Disable and then Overwrite in Hidden File Sync could silently skip hidden files, because the overwrite setup ran while hidden file synchronisation was still disabled (#989, PR #992).
- Hidden File Sync is now re-enabled before the Fetch, Overwrite, or Merge initialisation runs, instead of after it completes. If that initialisation fails, the setting may remain enabled.
## 0.25.79
29th June, 2026
### Fixed
- Fast Fetch now retries transient stream interruptions and resumes from the latest persisted checkpoint, instead of starting over after ordinary network or platform interruptions (#977, PR #978; commonlib PR #59). Thank you so much for @apple-ouyang for the fix!
- Simple Fetch now remembers the selected setup choices while an interrupted Fetch All operation is still pending, so users are not asked the same questions again on retry (#977, PR #978). Thank you so much for @apple-ouyang for the fix!
- No longer hidden storage events, such as `.git` paths, reach the normal target-file filter when internal file synchronisation is disabled. This avoids noisy non-target logs before those files are skipped (commonlib PR #60). Thank you so much for @apple-ouyang for the fix!
- Fixed an issue where a file deleted from storage could be resurrected by the offline scanner because the database tombstone was not written when the storage file was already gone (commonlib PR #56). Thank you so much for @cosmic-fire-eng for the fix!
### Improved
- Local database maintenance commands now ask before applying the required chunk settings, and can apply those prerequisites before continuing (#980, PR #981). Thank you so much for @apple-ouyang for the improvement!
- Improved CouchDB replication event handling by using the new `StreamInbox` helper from `octagonal-wheels` (commonlib PR #62).
### Documentation
- Added `nginx` to the setup documentation table of contents (PR #976). Thank you so much for @kiraventom for the improvement!
### Miscellaneous
- Updated `octagonal-wheels` to `0.1.47` across the plug-in and workspace packages to use the newly published helper modules.
## 0.25.78
23rd June, 2026
### Fixed
- No longer fast synchronisation (a.k.a. Fast Fetch) causes a rewind and re-fetch of the entire database when some errors occur during the process (#972, PR #973). Thank you so much for @apple-ouyang for the fix!
### Improved
- Overhauled the Object Storage (e.g., MinIO and S3) replication engine ('Journal Replicator 2nd Edition').
- It now leverages the standard Web Streams API for a resilient, backpressure-aware architecture, reducing memory footprints/temporary storage usage on large vaults.
- Decoupled the physical storage logic to make it easier to add new storage backends in the future.
- Stricter compliance with CouchDB's replication protocol (proper `_revisions` transfers with `new_edits: false`) when using Object Storage.
### Testing
- Added comprehensive unit tests for the new `JournalSyncCore` engine, covering streams, backpressure, and `new_edits: false` validation.
- Improved integration test workflows in the CI pipeline to run MinIO tests automatically using standard environment variables.
## 0.25.77
19th June, 2026