Compare commits

...

41 Commits

Author SHA1 Message Date
vorotamoroz
502ebafdda Merge remote-tracking branch 'origin/main' into test_real_obsidian 2026-05-14 16:09:25 +01:00
vorotamoroz
bba0a27735 WIP: Add test 2026-05-14 16:06:17 +01:00
vorotamoroz
91c9746886 Merge pull request #900 from vrtmrz/v0_25_62
Releasing v0.25.62
2026-05-14 20:15:02 +09:00
vorotamoroz
75b44b1636 bump 2026-05-14 09:40:12 +00:00
vorotamoroz
f1fe48c1ee add version-bump 2026-05-14 09:35:53 +00:00
vorotamoroz
437e7c0d9c Fixed an issue where a connection could not be established when attempting to connect to a brand-new remote database without going through the set-up wizard or configuration checking 2026-05-14 09:32:34 +00:00
vorotamoroz
02580b2cad Merge branch 'main' into test_real_obsidian 2026-05-14 05:28:38 +01:00
vorotamoroz
5ffa7ec7ee Merge pull request #897 from vrtmrz/v0_25_61
Releaseing v0.25.61
2026-05-13 23:06:57 +09:00
vorotamoroz
a1859f5d2e bump 2026-05-13 14:44:38 +01:00
vorotamoroz
785af8cb8f Merge pull request #896 from vrtmrz/address_community_review
Address community review
2026-05-13 22:29:37 +09:00
vorotamoroz
06e1f4aa4a Update subrepo pointer 2026-05-13 14:25:49 +01:00
vorotamoroz
767f22ce9c Merge branch 'address_community_review' of https://github.com/vrtmrz/obsidian-livesync into address_community_review 2026-05-13 14:16:51 +01:00
vorotamoroz
6a9bba702c chore: ran prettier 2026-05-13 14:10:56 +01:00
vorotamoroz
de2397dc3f Adding a rough DI 2026-05-13 14:10:55 +01:00
vorotamoroz
daaad9212e Fix package-lock 2026-05-13 14:10:54 +01:00
vorotamoroz
a6891374a1 chore: Package modernise, update linter 2026-05-13 14:10:01 +01:00
vorotamoroz
b1cadf0549 prettify 2026-05-13 14:07:58 +01:00
vorotamoroz
95f40cc954 (chore): removing DOM Operation 2026-05-13 14:07:58 +01:00
vorotamoroz
8deaf123d6 Update eslint config to ignore file,
fix some type error on LiveSyncBaseCore
2026-05-13 14:06:51 +01:00
vorotamoroz
053813bffb Update for review once 2026-05-13 14:06:51 +01:00
vorotamoroz
cc7af03618 chore: Package modernise, update linter 2026-05-13 14:06:51 +01:00
vorotamoroz
a130e3700e prettify 2026-05-13 14:06:50 +01:00
vorotamoroz
0549e901b2 (chore): removing DOM Operation 2026-05-13 14:06:49 +01:00
vorotamoroz
e9afe06968 Merge pull request #895 from vrtmrz/update_lib
Update lib to fix P2P problems and merging contributions
2026-05-13 22:01:17 +09:00
vorotamoroz
37715d4c9f chore: ran prettier 2026-05-13 11:12:40 +00:00
vorotamoroz
106367fa41 Adding a rough DI 2026-05-13 11:09:04 +00:00
vorotamoroz
538130aa91 Fix package-lock 2026-05-13 11:36:01 +01:00
vorotamoroz
c9d0357fec Merge branch 'address_community_review' of https://github.com/vrtmrz/obsidian-livesync into address_community_review 2026-05-13 11:35:01 +01:00
vorotamoroz
d05c76da36 Update eslint config to ignore file,
fix some type error on LiveSyncBaseCore
2026-05-13 11:33:46 +01:00
vorotamoroz
d2eb6ecbaf Update for review once 2026-05-13 11:33:46 +01:00
vorotamoroz
25a6fde212 chore: Package modernise, update linter 2026-05-13 11:33:45 +01:00
vorotamoroz
e8f8b680ef prettify 2026-05-13 11:33:03 +01:00
vorotamoroz
6c30f2b863 (chore): removing DOM Operation 2026-05-13 11:33:03 +01:00
vorotamoroz
770d4af4a0 Update eslint config to ignore file,
fix some type error on LiveSyncBaseCore
2026-05-13 10:15:45 +01:00
vorotamoroz
3b311248cb Update for review once 2026-05-13 08:02:50 +01:00
vorotamoroz
5772811a45 chore: Package modernise, update linter 2026-05-13 04:40:32 +01:00
vorotamoroz
55529cd71e prettify 2026-05-13 03:58:08 +01:00
vorotamoroz
2e9b8b7b62 (chore): removing DOM Operation 2026-05-13 03:55:11 +01:00
vorotamoroz
13bb44c9bb Merge branch 'test_real_obsidian' of https://github.com/vrtmrz/obsidian-livesync into test_real_obsidian 2026-05-12 02:18:06 +01:00
vorotamoroz
eeb508ed32 WIP: feat(test): Add framework of real-Obsidian based e2e test 2026-05-12 02:17:18 +01:00
vorotamoroz
edf85184c1 WIP: feat(test): Add framework of real-Obsidian based e2e test 2026-05-11 03:33:11 +01:00
43 changed files with 2292 additions and 320 deletions

4
.gitignore vendored
View File

@@ -29,3 +29,7 @@ cov_profile/**
coverage coverage
src/apps/cli/dist/* src/apps/cli/dist/*
# Obsidian E2E test artefacts
test_e2e/playwright-report/
test_e2e/test-results/

View File

@@ -2,7 +2,6 @@
import esbuild from "esbuild"; import esbuild from "esbuild";
import process from "process"; import process from "process";
import builtins from "builtin-modules";
import sveltePlugin from "esbuild-svelte"; import sveltePlugin from "esbuild-svelte";
import { sveltePreprocess } from "svelte-preprocess"; import { sveltePreprocess } from "svelte-preprocess";
import fs from "node:fs"; import fs from "node:fs";

View File

@@ -1,103 +1,83 @@
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import svelte from "eslint-plugin-svelte";
import _import from "eslint-plugin-import";
import { fixupPluginRules } from "@eslint/compat";
import tsParser from "@typescript-eslint/parser"; import tsParser from "@typescript-eslint/parser";
import path from "node:path"; import obsidianmd from "eslint-plugin-obsidianmd";
import { fileURLToPath } from "node:url"; import globals from "globals";
import js from "@eslint/js"; import { defineConfig, globalIgnores } from "eslint/config";
import { FlatCompat } from "@eslint/eslintrc"; import * as sveltePlugin from "eslint-plugin-svelte";
const __filename = fileURLToPath(import.meta.url); export default defineConfig([
const __dirname = path.dirname(__filename); globalIgnores([
const compat = new FlatCompat({ "**/node_modules/*",
baseDirectory: __dirname, "**/jest.config.js",
recommendedConfig: js.configs.recommended, "src/lib/coverage",
allConfig: js.configs.all, "src/lib/browsertest",
}); "**/test.ts",
"**/tests.ts",
export default [ "**/**test.ts",
"**/**.test.ts",
"**/*.unit.spec.ts",
"**/esbuild.*.mjs",
"**/terser.*.mjs",
"**/node_modules",
"**/build",
"**/.eslintrc.js.bak",
"src/lib/src/patches/pouchdb-utils",
"**/esbuild.config.mjs",
"**/rollup.config.js",
"modules/octagonal-wheels/rollup.config.js",
"modules/octagonal-wheels/dist/**/*",
"src/lib/test",
"src/lib/_tools",
"src/lib/src/cli",
"**/main.js",
"src/apps/**/*",
".prettierrc.*.mjs",
".prettierrc.mjs",
"*.config.mjs",
"src/apps/**/*",
"src/lib/src/services/implements/browser/**",
"src/lib/src/services/implements/headless/**",
"src/lib/src/API",
]),
...sveltePlugin.configs["flat/base"],
...obsidianmd.configs.recommended,
{ {
ignores: [ files: ["**/*.ts"],
"**/node_modules/*",
"**/jest.config.js",
"src/lib/coverage",
"src/lib/browsertest",
"**/test.ts",
"**/tests.ts",
"**/**test.ts",
"**/**.test.ts",
"**/esbuild.*.mjs",
"**/terser.*.mjs",
"**/node_modules",
"**/build",
"**/.eslintrc.js.bak",
"src/lib/src/patches/pouchdb-utils",
"**/esbuild.config.mjs",
"**/rollup.config.js",
"modules/octagonal-wheels/rollup.config.js",
"modules/octagonal-wheels/dist/**/*",
"src/lib/test",
"src/lib/_tools",
"src/lib/src/cli",
"**/main.js",
"src/apps/**/*",
".prettierrc.*.mjs",
".prettierrc.mjs",
"*.config.mjs"
],
},
...compat.extends(
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
),
{
plugins: {
"@typescript-eslint": typescriptEslint,
svelte,
import: fixupPluginRules(_import),
},
languageOptions: { languageOptions: {
globals: { ...globals.browser },
parser: tsParser, parser: tsParser,
ecmaVersion: 5,
sourceType: "module",
parserOptions: { parserOptions: {
project: ["tsconfig.json"], project: "./tsconfig.json",
}, },
}, },
rules: { rules: {
"no-unused-vars": "off", "no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { args: "none" }],
"@typescript-eslint/no-unused-vars": [
"error",
{
args: "none",
},
],
"no-unused-labels": "off", "no-unused-labels": "off",
"@typescript-eslint/ban-ts-comment": "off", "@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off", "no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off", "@typescript-eslint/no-empty-function": "off",
"require-await": "error", "require-await": "error",
"obsidianmd/rule-custom-message": "off", // Temporary
"obsidianmd/ui/sentence-case": "off", // Temporary
"@typescript-eslint/require-await": "warn", "@typescript-eslint/require-await": "warn",
"@typescript-eslint/no-misused-promises": "warn", "@typescript-eslint/no-misused-promises": "warn",
"@typescript-eslint/no-floating-promises": "warn", "@typescript-eslint/no-floating-promises": "warn",
"no-async-promise-executor": "warn", "no-async-promise-executor": "warn",
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unnecessary-type-assertion": "error", "@typescript-eslint/no-unnecessary-type-assertion": "error",
"no-constant-condition": ["error", { checkLoops: false }],
"no-constant-condition": [
"error",
{
checkLoops: false,
},
],
}, },
}, },
]; {
files: ["**/*.svelte"],
languageOptions: {
parserOptions: {
parser: tsParser,
},
},
rules: {
"no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
"obsidianmd/no-plugin-as-component": "off", // Temporary
},
},
]);

View File

@@ -1,8 +1,8 @@
{ {
"id": "obsidian-livesync", "id": "obsidian-livesync",
"name": "Self-hosted LiveSync", "name": "Self-hosted LiveSync",
"version": "0.25.60", "version": "0.25.62",
"minAppVersion": "0.9.12", "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.", "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", "author": "vorotamoroz",
"authorUrl": "https://github.com/vrtmrz", "authorUrl": "https://github.com/vrtmrz",

1391
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "0.25.60", "version": "0.25.62",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.", "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", "main": "main.js",
"type": "module", "type": "module",
@@ -54,15 +54,17 @@
"test:docker-all:start": "npm run test:docker-all:up && sleep 5 && npm run test:docker-all:init", "test:docker-all:start": "npm run test:docker-all:up && sleep 5 && npm run test:docker-all:init",
"test:docker-all:stop": "npm run test:docker-all:down", "test:docker-all:stop": "npm run test:docker-all:down",
"test:full": "npm run test:docker-all:start && vitest run --coverage && npm run test:docker-all:stop", "test:full": "npm run test:docker-all:start && vitest run --coverage && npm run test:docker-all:stop",
"test:p2p": "bash test/suitep2p/run-p2p-tests.sh" "test:p2p": "bash test/suitep2p/run-p2p-tests.sh",
"test:obsidian:e2e": "npx playwright test --config test_e2e/playwright.config.ts",
"test:obsidian:e2e:headed": "npx playwright test --config test_e2e/playwright.config.ts --headed",
"test:obsidian:build-and-e2e": "npm run buildDev && npm run test:obsidian:e2e",
"version": "node version-bump.mjs && git add manifest.json versions.json"
}, },
"keywords": [], "keywords": [],
"author": "vorotamoroz", "author": "vorotamoroz",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@chialab/esbuild-plugin-worker": "^0.19.0", "@chialab/esbuild-plugin-worker": "^0.19.0",
"@eslint/compat": "^2.0.2",
"@eslint/eslintrc": "^3.3.4",
"@eslint/js": "^9.39.3", "@eslint/js": "^9.39.3",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tsconfig/svelte": "^5.0.8", "@tsconfig/svelte": "^5.0.8",
@@ -84,18 +86,15 @@
"@vitest/browser": "^4.1.1", "@vitest/browser": "^4.1.1",
"@vitest/browser-playwright": "^4.1.1", "@vitest/browser-playwright": "^4.1.1",
"@vitest/coverage-v8": "^4.1.1", "@vitest/coverage-v8": "^4.1.1",
"builtin-modules": "5.0.0",
"dotenv": "^17.3.1",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"esbuild": "0.25.0", "esbuild": "0.25.0",
"esbuild-plugin-inline-worker": "^0.1.1", "esbuild-plugin-inline-worker": "^0.1.1",
"esbuild-svelte": "^0.9.4", "esbuild-svelte": "^0.9.4",
"eslint": "^9.39.3", "eslint": "^9.39.3",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-obsidianmd": "^0.3.0",
"eslint-plugin-svelte": "^3.15.0", "eslint-plugin-svelte": "^3.15.0",
"events": "^3.3.0", "events": "^3.3.0",
"glob": "^13.0.6", "globals": "^14.0.0",
"obsidian": "^1.12.3",
"playwright": "^1.58.2", "playwright": "^1.58.2",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"postcss-load-config": "^6.0.1", "postcss-load-config": "^6.0.1",
@@ -116,6 +115,7 @@
"svelte-check": "^4.4.3", "svelte-check": "^4.4.3",
"svelte-preprocess": "^6.0.3", "svelte-preprocess": "^6.0.3",
"terser": "^5.39.0", "terser": "^5.39.0",
"tinyglobby": "^0.2.15",
"transform-pouch": "^2.0.0", "transform-pouch": "^2.0.0",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"tsx": "^4.21.0", "tsx": "^4.21.0",
@@ -136,6 +136,7 @@
"@trystero-p2p/nostr": "^0.23.0", "@trystero-p2p/nostr": "^0.23.0",
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"commander": "^14.0.3", "commander": "^14.0.3",
"obsidian": "^1.12.3",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
"fflate": "^0.8.2", "fflate": "^0.8.2",
"idb": "^8.0.3", "idb": "^8.0.3",

View File

@@ -1,4 +1,5 @@
import { LOG_LEVEL_INFO } from "octagonal-wheels/common/logger"; import { LOG_LEVEL_INFO } from "octagonal-wheels/common/logger";
import type PouchDB from "pouchdb-core";
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase"; import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
import type { HasSettings, ObsidianLiveSyncSettings, EntryDoc } from "./lib/src/common/types"; import type { HasSettings, ObsidianLiveSyncSettings, EntryDoc } from "./lib/src/common/types";
import { __$checkInstanceBinding } from "./lib/src/dev/checks"; import { __$checkInstanceBinding } from "./lib/src/dev/checks";
@@ -123,7 +124,7 @@ export class LiveSyncBaseCore<
for (const module of this.modules) { for (const module of this.modules) {
if (module.constructor === constructor) return module as T; if (module.constructor === constructor) return module as T;
} }
throw new Error(`Module ${constructor} not found or not loaded.`); throw new Error(`Module ${constructor.name} not found or not loaded.`);
} }
/** /**
@@ -160,8 +161,10 @@ export class LiveSyncBaseCore<
module.onBindFunction(this, this.services); module.onBindFunction(this, this.services);
__$checkInstanceBinding(module); // Check if all functions are properly bound, and log warnings if not. __$checkInstanceBinding(module); // Check if all functions are properly bound, and log warnings if not.
} else { } else {
// module should not be never.
const moduleName = (module as unknown)?.constructor?.name ?? "unknown";
this.services.API.addLog( this.services.API.addLog(
`Module ${(module as any)?.constructor?.name ?? "unknown"} does not have onBindFunction, skipping binding.`, `Module ${moduleName} does not have onBindFunction, skipping binding.`,
LOG_LEVEL_INFO LOG_LEVEL_INFO
); );
} }

View File

@@ -257,12 +257,12 @@ describe("daemon command", () => {
// failure 1: 30000*2=60000, failure 2: 30000*4=120000, // failure 1: 30000*2=60000, failure 2: 30000*4=120000,
// failure 3: 30000*8=240000, failure 4: 30000*16=480000→capped, 5→cap, 6→cap // failure 3: 30000*8=240000, failure 4: 30000*16=480000→capped, 5→cap, 6→cap
const expectedIntervals = [ const expectedIntervals = [
baseMs * 2, // after failure 1: 60000 baseMs * 2, // after failure 1: 60000
baseMs * 4, // after failure 2: 120000 baseMs * 4, // after failure 2: 120000
baseMs * 8, // after failure 3: 240000 baseMs * 8, // after failure 3: 240000
300_000, // after failure 4 (would be 480000, capped) 300_000, // after failure 4 (would be 480000, capped)
300_000, // after failure 5 (cap) 300_000, // after failure 5 (cap)
300_000, // after failure 6 (cap) 300_000, // after failure 6 (cap)
]; ];
for (const expected of expectedIntervals) { for (const expected of expectedIntervals) {

View File

@@ -43,10 +43,13 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
// 3. Re-enable sync. // 3. Re-enable sync.
const restoreSyncSettings = async () => { const restoreSyncSettings = async () => {
await core.services.setting.applyPartial({ await core.services.setting.applyPartial(
...context.originalSyncSettings, {
suspendFileWatching: false, ...context.originalSyncSettings,
}, true); suspendFileWatching: false,
},
true
);
// applySettings fires the full lifecycle: onSuspending → onResumed. // applySettings fires the full lifecycle: onSuspending → onResumed.
// ModuleReplicatorCouchDB starts continuous replication on onResumed // ModuleReplicatorCouchDB starts continuous replication on onResumed
// via fireAndForget. // via fireAndForget.
@@ -54,10 +57,13 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
// Lifecycle events (onSuspending) may re-enable suspension flags. // Lifecycle events (onSuspending) may re-enable suspension flags.
// Clear them explicitly after the lifecycle completes. applyPartial // Clear them explicitly after the lifecycle completes. applyPartial
// with true is a direct store write — it does not re-trigger lifecycle. // with true is a direct store write — it does not re-trigger lifecycle.
await core.services.setting.applyPartial({ await core.services.setting.applyPartial(
suspendFileWatching: false, {
suspendParseReplicationResult: false, suspendFileWatching: false,
}, true); suspendParseReplicationResult: false,
},
true
);
}; };
if (options.interval) { if (options.interval) {
log(`Polling mode: syncing every ${options.interval}s`); log(`Polling mode: syncing every ${options.interval}s`);
@@ -80,7 +86,9 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
currentIntervalMs = Math.min(baseIntervalMs * Math.pow(2, consecutiveFailures), maxIntervalMs); currentIntervalMs = Math.min(baseIntervalMs * Math.pow(2, consecutiveFailures), maxIntervalMs);
console.error(`[Daemon] Poll error (${consecutiveFailures} consecutive):`, err); console.error(`[Daemon] Poll error (${consecutiveFailures} consecutive):`, err);
if (consecutiveFailures >= 5) { if (consecutiveFailures >= 5) {
console.error(`[Daemon] Warning: ${consecutiveFailures} consecutive failures, backing off to ${Math.round(currentIntervalMs / 1000)}s`); console.error(
`[Daemon] Warning: ${consecutiveFailures} consecutive failures, backing off to ${Math.round(currentIntervalMs / 1000)}s`
);
} }
} }
pollTimer = setTimeout(poll, currentIntervalMs); pollTimer = setTimeout(poll, currentIntervalMs);
@@ -99,9 +107,11 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
log("LiveSync active"); log("LiveSync active");
const currentSettings = core.services.setting.currentSettings(); const currentSettings = core.services.setting.currentSettings();
if (!currentSettings.liveSync && !currentSettings.syncOnStart) { if (!currentSettings.liveSync && !currentSettings.syncOnStart) {
console.error("[Daemon] Warning: liveSync and syncOnStart are both disabled in settings. " + console.error(
"No sync will occur. Set liveSync=true in your settings file for continuous sync, " + "[Daemon] Warning: liveSync and syncOnStart are both disabled in settings. " +
"or use --interval for polling mode."); "No sync will occur. Set liveSync=true in your settings file for continuous sync, " +
"or use --interval for polling mode."
);
} }
} }

View File

@@ -37,7 +37,16 @@ export interface CLICommandContext {
databasePath: string; databasePath: string;
core: LiveSyncBaseCore<ServiceContext, any>; core: LiveSyncBaseCore<ServiceContext, any>;
settingsPath: string; settingsPath: string;
originalSyncSettings: Pick<ObsidianLiveSyncSettings, "liveSync" | "syncOnStart" | "periodicReplication" | "syncOnSave" | "syncOnEditorSave" | "syncOnFileOpen" | "syncAfterMerge">; originalSyncSettings: Pick<
ObsidianLiveSyncSettings,
| "liveSync"
| "syncOnStart"
| "periodicReplication"
| "syncOnSave"
| "syncOnEditorSave"
| "syncOnFileOpen"
| "syncAfterMerge"
>;
} }
export const VALID_COMMANDS = new Set([ export const VALID_COMMANDS = new Set([

View File

@@ -280,16 +280,13 @@ export async function main() {
// chokidar's ignored option is populated when beginWatch() fires during onLoad(). // chokidar's ignored option is populated when beginWatch() fires during onLoad().
const watchEnabled = options.command === "daemon"; const watchEnabled = options.command === "daemon";
const vaultPath = const vaultPath =
options.command === "mirror" && options.commandArgs[0] options.command === "mirror" && options.commandArgs[0] ? path.resolve(options.commandArgs[0]) : databasePath;
? path.resolve(options.commandArgs[0])
: databasePath;
let ignoreRules: IgnoreRules | undefined; let ignoreRules: IgnoreRules | undefined;
if (options.command === "daemon" || options.command === "mirror") { if (options.command === "daemon" || options.command === "mirror") {
ignoreRules = new IgnoreRules(vaultPath); ignoreRules = new IgnoreRules(vaultPath);
await ignoreRules.load(); await ignoreRules.load();
} }
// Create service context and hub // Create service context and hub
const context = new NodeServiceContext(databasePath); const context = new NodeServiceContext(databasePath);
const serviceHubInstance = new NodeServiceHub<NodeServiceContext>(databasePath, context); const serviceHubInstance = new NodeServiceHub<NodeServiceContext>(databasePath, context);

View File

@@ -97,7 +97,11 @@ class CLIConverterAdapter implements IStorageEventConverterAdapter<NodeFile> {
class CLIWatchAdapter implements IStorageEventWatchAdapter { class CLIWatchAdapter implements IStorageEventWatchAdapter {
private _watcher: FSWatcher | undefined; private _watcher: FSWatcher | undefined;
constructor(private basePath: string, private ignoreRules?: IgnoreRules, private watchEnabled: boolean = false) {} constructor(
private basePath: string,
private ignoreRules?: IgnoreRules,
private watchEnabled: boolean = false
) {}
private _toNodeFile(filePath: string, stats: Stats | undefined): NodeFile { private _toNodeFile(filePath: string, stats: Stats | undefined): NodeFile {
return { return {

View File

@@ -60,10 +60,7 @@ describe("CLIStorageEventManagerAdapter", () => {
await adapter.watch.beginWatch(handlers); await adapter.watch.beginWatch(handlers);
expect(chokidar.watch).toHaveBeenCalledTimes(1); expect(chokidar.watch).toHaveBeenCalledTimes(1);
expect(chokidar.watch).toHaveBeenCalledWith( expect(chokidar.watch).toHaveBeenCalledWith("/base", expect.objectContaining({ ignoreInitial: true }));
"/base",
expect.objectContaining({ ignoreInitial: true })
);
}); });
it("add event produces NodeFile with correct relative path via onCreate", async () => { it("add event produces NodeFile with correct relative path via onCreate", async () => {

View File

@@ -25,7 +25,7 @@ export function initialiseServiceModulesCLI(
core: LiveSyncBaseCore<ServiceContext, any>, core: LiveSyncBaseCore<ServiceContext, any>,
services: InjectableServiceHub<ServiceContext>, services: InjectableServiceHub<ServiceContext>,
ignoreRules?: IgnoreRules, ignoreRules?: IgnoreRules,
watchEnabled: boolean = false, watchEnabled: boolean = false
): ServiceModules { ): ServiceModules {
const storageAccessManager = new StorageAccessManager(); const storageAccessManager = new StorageAccessManager();
@@ -39,13 +39,19 @@ export function initialiseServiceModulesCLI(
}); });
// CLI-specific storage event manager // CLI-specific storage event manager
const storageEventManager = new StorageEventManagerCLI(basePath, core, { const storageEventManager = new StorageEventManagerCLI(
fileProcessing: services.fileProcessing, basePath,
setting: services.setting, core,
vaultService: services.vault, {
storageAccessManager: storageAccessManager, fileProcessing: services.fileProcessing,
APIService: services.API, setting: services.setting,
}, ignoreRules, watchEnabled); vaultService: services.vault,
storageAccessManager: storageAccessManager,
APIService: services.API,
},
ignoreRules,
watchEnabled
);
// Close the file watcher during graceful shutdown so the process can exit cleanly. // Close the file watcher during graceful shutdown so the process can exit cleanly.
services.appLifecycle.onUnload.addHandler(async () => { services.appLifecycle.onUnload.addHandler(async () => {

View File

@@ -55,7 +55,9 @@ export class IgnoreRules {
continue; continue;
} }
if (trimmed.startsWith("import:")) { if (trimmed.startsWith("import:")) {
console.error(`[IgnoreRules] Warning: unrecognised directive '${trimmed}' — only 'import: .gitignore' is supported`); console.error(
`[IgnoreRules] Warning: unrecognised directive '${trimmed}' — only 'import: .gitignore' is supported`
);
continue; continue;
} }
this._addPattern(trimmed); this._addPattern(trimmed);
@@ -105,7 +107,7 @@ export class IgnoreRules {
if (raw.startsWith("!")) { if (raw.startsWith("!")) {
throw new Error( throw new Error(
`[IgnoreRules] Negation pattern '${raw}' is not supported. ` + `[IgnoreRules] Negation pattern '${raw}' is not supported. ` +
`Remove it from .livesync/ignore or use a separate include/exclude file.` `Remove it from .livesync/ignore or use a separate include/exclude file.`
); );
} }
this.patterns.push(this._normalisePattern(raw)); this.patterns.push(this._normalisePattern(raw));

View File

@@ -122,10 +122,7 @@ describe("IgnoreRules", () => {
describe("load() with comments and blank lines", () => { describe("load() with comments and blank lines", () => {
it("skips # comment lines and blank lines", async () => { it("skips # comment lines and blank lines", async () => {
const vaultPath = await createVault(); const vaultPath = await createVault();
await writeIgnoreFile( await writeIgnoreFile(vaultPath, "# This is a comment\n\n \n*.tmp\n# another comment\nbuild/\n");
vaultPath,
"# This is a comment\n\n \n*.tmp\n# another comment\nbuild/\n"
);
const rules = new IgnoreRules(vaultPath); const rules = new IgnoreRules(vaultPath);
await rules.load(); await rules.load();
expect(rules.shouldIgnore("scratch.tmp")).toBe(true); expect(rules.shouldIgnore("scratch.tmp")).toBe(true);

View File

@@ -29,7 +29,8 @@ export async function runScenario(remoteType: RemoteType, encrypt: boolean): Pro
const dbPrefix = remoteType === "COUCHDB" ? requireEnv("COUCHDB_DBNAME", "dbname") : ""; const dbPrefix = remoteType === "COUCHDB" ? requireEnv("COUCHDB_DBNAME", "dbname") : "";
const dbname = remoteType === "COUCHDB" ? `${dbPrefix}-${dbSuffix}` : ""; const dbname = remoteType === "COUCHDB" ? `${dbPrefix}-${dbSuffix}` : "";
const minioEndpoint = remoteType === "MINIO" ? requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/, "") : ""; const minioEndpoint =
remoteType === "MINIO" ? requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/, "") : "";
const minioAccessKey = remoteType === "MINIO" ? requireEnv("MINIO_ACCESS_KEY", "accessKey") : ""; const minioAccessKey = remoteType === "MINIO" ? requireEnv("MINIO_ACCESS_KEY", "accessKey") : "";
const minioSecretKey = remoteType === "MINIO" ? requireEnv("MINIO_SECRET_KEY", "secretKey") : ""; const minioSecretKey = remoteType === "MINIO" ? requireEnv("MINIO_SECRET_KEY", "secretKey") : "";
const minioBucketBase = remoteType === "MINIO" ? requireEnv("MINIO_BUCKET_NAME", "bucketName") : ""; const minioBucketBase = remoteType === "MINIO" ? requireEnv("MINIO_BUCKET_NAME", "bucketName") : "";

View File

@@ -47,7 +47,8 @@ function injectBanner(): import("vite").Plugin {
// Insert after the shebang line if present, otherwise at the top. // Insert after the shebang line if present, otherwise at the top.
if (chunk.code.startsWith("#!")) { if (chunk.code.startsWith("#!")) {
const newline = chunk.code.indexOf("\n"); const newline = chunk.code.indexOf("\n");
chunk.code = chunk.code.slice(0, newline + 1) + fileReaderPolyfillBanner + chunk.code.slice(newline + 1); chunk.code =
chunk.code.slice(0, newline + 1) + fileReaderPolyfillBanner + chunk.code.slice(newline + 1);
} else { } else {
chunk.code = fileReaderPolyfillBanner + chunk.code; chunk.code = fileReaderPolyfillBanner + chunk.code;
} }

View File

@@ -41,7 +41,7 @@ async function renderHistoryList(): Promise<VaultHistoryItem[]> {
const [items, lastUsedId] = await Promise.all([historyStore.getVaultHistory(), historyStore.getLastUsedVaultId()]); const [items, lastUsedId] = await Promise.all([historyStore.getVaultHistory(), historyStore.getLastUsedVaultId()]);
listEl.innerHTML = ""; listEl.replaceChildren();
emptyEl.classList.toggle("is-hidden", items.length > 0); emptyEl.classList.toggle("is-hidden", items.length > 0);
for (const item of items) { for (const item of items) {

Submodule src/lib updated: adcfe42522...ed4502e003

View File

@@ -1,5 +1,6 @@
import { Notice, Plugin, type App, type PluginManifest } from "./deps"; import { getLanguage, Notice, Plugin, type App, type PluginManifest } from "./deps";
import { setGetLanguage } from "./lib/src/common/coreEnvFunctions.ts";
setGetLanguage(getLanguage);
import { LiveSyncCommands } from "./features/LiveSyncCommands.ts"; import { LiveSyncCommands } from "./features/LiveSyncCommands.ts";
import { HiddenFileSync } from "./features/HiddenFileSync/CmdHiddenFileSync.ts"; import { HiddenFileSync } from "./features/HiddenFileSync/CmdHiddenFileSync.ts";
import { ConfigSync } from "./features/ConfigSync/CmdConfigSync.ts"; import { ConfigSync } from "./features/ConfigSync/CmdConfigSync.ts";

View File

@@ -1,6 +1,6 @@
import { TFile, Modal, App, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "../../../deps.ts"; import { TFile, Modal, App, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "../../../deps.ts";
import { getPathFromTFile, isValidPath } from "../../../common/utils.ts"; import { getPathFromTFile, isValidPath } from "../../../common/utils.ts";
import { decodeBinary, escapeStringToHTML, readString } from "../../../lib/src/string_and_binary/convert.ts"; import { decodeBinary, readString } from "../../../lib/src/string_and_binary/convert.ts";
import ObsidianLiveSyncPlugin from "../../../main.ts"; import ObsidianLiveSyncPlugin from "../../../main.ts";
import { import {
type DocumentID, type DocumentID,
@@ -145,22 +145,66 @@ export class DocumentHistoryModal extends Modal {
return v; return v;
} }
prepareContentView(usePreformatted = true) {
this.contentView.empty();
this.contentView.toggleClass("op-pre", usePreformatted);
}
appendTextDiff(diff: [number, string][]) {
for (const [operation, text] of diff) {
if (operation == DIFF_DELETE) {
this.contentView.createSpan({ text, cls: "history-deleted" });
} else if (operation == DIFF_EQUAL) {
this.contentView.createSpan({ text, cls: "history-normal" });
} else if (operation == DIFF_INSERT) {
this.contentView.createSpan({ text, cls: "history-added" });
}
}
}
appendImageDiff(baseSrc: string, overlaySrc?: string) {
const wrap = this.contentView.createDiv({ cls: "ls-imgdiff-wrap" });
const overlay = wrap.createDiv({ cls: "overlay" });
overlay.createEl("img", { cls: "img-base" }, (img) => {
img.src = baseSrc;
});
if (overlaySrc) {
overlay.createEl("img", { cls: "img-overlay" }, (img) => {
img.src = overlaySrc;
});
}
}
appendDeletedNotice(usePreformatted = true) {
const notice = "(At this revision, the file has been deleted)";
if (usePreformatted) {
this.contentView.appendText(`${notice}\n`);
} else {
this.contentView.createDiv({ text: notice });
}
}
async showExactRev(rev: string) { async showExactRev(rev: string) {
const db = this.core.localDatabase; const db = this.core.localDatabase;
const w = await db.getDBEntry(this.file, { rev: rev }, false, false, true); const w = await db.getDBEntry(this.file, { rev: rev }, false, false, true);
this.currentText = ""; this.currentText = "";
this.currentDeleted = false; this.currentDeleted = false;
this.prepareContentView();
if (w === false) { if (w === false) {
this.currentDeleted = true; this.currentDeleted = true;
this.info.innerHTML = ""; this.info.empty();
this.contentView.innerHTML = `Could not read this revision<br>(${rev})`; this.contentView.appendText("Could not read this revision");
this.contentView.createEl("br");
this.contentView.appendText(`(${rev})`);
} else { } else {
this.currentDoc = w; this.currentDoc = w;
this.info.innerHTML = `Modified:${new Date(w.mtime).toLocaleString()}`; this.info.setText(`Modified:${new Date(w.mtime).toLocaleString()}`);
let result = undefined;
const w1data = readDocument(w); const w1data = readDocument(w);
this.currentDeleted = !!w.deleted; this.currentDeleted = !!w.deleted;
// this.currentText = w1data; if (typeof w1data == "string") {
this.currentText = w1data;
}
let rendered = false;
if (this.showDiff) { if (this.showDiff) {
const prevRevIdx = this.revs_info.length - 1 - ((this.range.value as any) / 1 - 1); const prevRevIdx = this.revs_info.length - 1 - ((this.range.value as any) / 1 - 1);
if (prevRevIdx >= 0 && prevRevIdx < this.revs_info.length) { if (prevRevIdx >= 0 && prevRevIdx < this.revs_info.length) {
@@ -168,58 +212,55 @@ export class DocumentHistoryModal extends Modal {
const w2 = await db.getDBEntry(this.file, { rev: oldRev }, false, false, true); const w2 = await db.getDBEntry(this.file, { rev: oldRev }, false, false, true);
if (w2 != false) { if (w2 != false) {
if (typeof w1data == "string") { if (typeof w1data == "string") {
result = ""; const w2data = readDocument(w2);
const dmp = new diff_match_patch(); if (typeof w2data == "string") {
const w2data = readDocument(w2) as string; const dmp = new diff_match_patch();
const diff = dmp.diff_main(w2data, w1data); const diff = dmp.diff_main(w2data, w1data);
dmp.diff_cleanupSemantic(diff); dmp.diff_cleanupSemantic(diff);
for (const v of diff) { if (this.currentDeleted) {
const x1 = v[0]; this.appendDeletedNotice();
const x2 = v[1];
if (x1 == DIFF_DELETE) {
result += "<span class='history-deleted'>" + escapeStringToHTML(x2) + "</span>";
} else if (x1 == DIFF_EQUAL) {
result += "<span class='history-normal'>" + escapeStringToHTML(x2) + "</span>";
} else if (x1 == DIFF_INSERT) {
result += "<span class='history-added'>" + escapeStringToHTML(x2) + "</span>";
} }
this.appendTextDiff(diff);
rendered = true;
} }
result = result.replace(/\n/g, "<br>");
} else if (isImage(this.file)) { } else if (isImage(this.file)) {
const src = this.generateBlobURL("base", w1data); const src = this.generateBlobURL("base", w1data);
const overlay = this.generateBlobURL( const overlay = this.generateBlobURL(
"overlay", "overlay",
readDocument(w2) as Uint8Array<ArrayBuffer> readDocument(w2) as Uint8Array<ArrayBuffer>
); );
result = `<div class='ls-imgdiff-wrap'> this.prepareContentView(false);
<div class='overlay'> if (this.currentDeleted) {
<img class='img-base' src="${src}"> this.appendDeletedNotice(false);
<img class='img-overlay' src='${overlay}'> }
</div> this.appendImageDiff(src, overlay);
</div>`; rendered = true;
this.contentView.removeClass("op-pre");
} }
} }
} }
} }
if (result == undefined) { if (!rendered) {
if (typeof w1data != "string") { if (typeof w1data != "string") {
if (isImage(this.file)) { if (isImage(this.file)) {
const src = this.generateBlobURL("base", w1data); const src = this.generateBlobURL("base", w1data);
result = `<div class='ls-imgdiff-wrap'> this.prepareContentView(false);
<div class='overlay'> if (this.currentDeleted) {
<img class='img-base' src="${src}"> this.appendDeletedNotice(false);
</div> }
</div>`; this.appendImageDiff(src);
this.contentView.removeClass("op-pre"); } else {
if (this.currentDeleted) {
this.appendDeletedNotice();
}
this.contentView.appendText("Binary file");
} }
} else { } else {
result = escapeStringToHTML(w1data); if (this.currentDeleted) {
this.appendDeletedNotice();
}
this.contentView.appendText(w1data);
} }
} }
if (result == undefined) result = typeof w1data == "string" ? escapeStringToHTML(w1data) : "Binary file";
this.contentView.innerHTML =
(this.currentDeleted ? "(At this revision, the file has been deleted)\n" : "") + result;
} }
// Reset diff navigation after content changes // Reset diff navigation after content changes
this.resetDiffNavigation(); this.resetDiffNavigation();
@@ -245,8 +286,7 @@ export class DocumentHistoryModal extends Modal {
if (direction === "next") { if (direction === "next") {
this.currentDiffIndex = (this.currentDiffIndex + 1) % diffElements.length; this.currentDiffIndex = (this.currentDiffIndex + 1) % diffElements.length;
} else { } else {
this.currentDiffIndex = this.currentDiffIndex = this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1;
this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1;
} }
const target = diffElements[this.currentDiffIndex]; const target = diffElements[this.currentDiffIndex];

View File

@@ -1,7 +1,6 @@
import { App, Modal } from "../../../deps.ts"; import { App, Modal } from "../../../deps.ts";
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from "diff-match-patch"; import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from "diff-match-patch";
import { CANCELLED, LEAVE_TO_SUBSEQUENT, type diff_result } from "../../../lib/src/common/types.ts"; import { CANCELLED, LEAVE_TO_SUBSEQUENT, type diff_result } from "../../../lib/src/common/types.ts";
import { escapeStringToHTML } from "../../../lib/src/string_and_binary/convert.ts";
import { delay } from "../../../lib/src/common/utils.ts"; import { delay } from "../../../lib/src/common/utils.ts";
import { eventHub } from "../../../common/events.ts"; import { eventHub } from "../../../common/events.ts";
import { globalSlipBoard } from "../../../lib/src/bureau/bureau.ts"; import { globalSlipBoard } from "../../../lib/src/bureau/bureau.ts";
@@ -44,6 +43,25 @@ export class ConflictResolveModal extends Modal {
// sendValue("close-resolve-conflict:" + this.filename, false); // sendValue("close-resolve-conflict:" + this.filename, false);
} }
appendDiffFragment(container: HTMLDivElement, text: string, cls: string) {
const lines = text.split("\n");
lines.forEach((line, index) => {
const span = container.createSpan({ cls });
span.textContent = line;
if (index < lines.length - 1) {
container.createSpan({ cls: "ls-mark-cr" });
container.createEl("br");
}
});
}
appendVersionInfo(container: HTMLDivElement, cls: string, name: string, date: string) {
const line = container.createSpan({ cls });
line.createSpan({ text: name, cls: "conflict-dev-name" });
line.appendText(`: ${date}`);
container.createEl("br");
}
override onOpen() { override onOpen() {
const { contentEl } = this; const { contentEl } = this;
// Send cancel signal for the previous merge dialogue // Send cancel signal for the previous merge dialogue
@@ -64,25 +82,21 @@ export class ConflictResolveModal extends Modal {
const div = contentEl.createDiv(""); const div = contentEl.createDiv("");
div.addClass("op-scrollable"); div.addClass("op-scrollable");
div.addClass("ls-dialog"); div.addClass("ls-dialog");
let diff = ""; let diffLength = 0;
for (const v of this.result.diff) { for (const v of this.result.diff) {
const x1 = v[0]; const x1 = v[0];
const x2 = v[1]; const x2 = v[1];
diffLength += x2.length;
if (diffLength > 100 * 1024) {
continue;
}
if (x1 == DIFF_DELETE) { if (x1 == DIFF_DELETE) {
diff += this.appendDiffFragment(div, x2, "deleted");
"<span class='deleted'>" + div.createEl("span", { text: x2, cls: "deleted normal conflict-dev-name" });
escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") +
"</span>";
} else if (x1 == DIFF_EQUAL) { } else if (x1 == DIFF_EQUAL) {
diff += this.appendDiffFragment(div, x2, "normal");
"<span class='normal'>" +
escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") +
"</span>";
} else if (x1 == DIFF_INSERT) { } else if (x1 == DIFF_INSERT) {
diff += this.appendDiffFragment(div, x2, "added");
"<span class='added'>" +
escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") +
"</span>";
} }
} }
@@ -92,8 +106,8 @@ export class ConflictResolveModal extends Modal {
new Date(this.result.left.mtime).toLocaleString() + (this.result.left.deleted ? " (Deleted)" : ""); new Date(this.result.left.mtime).toLocaleString() + (this.result.left.deleted ? " (Deleted)" : "");
const date2 = const date2 =
new Date(this.result.right.mtime).toLocaleString() + (this.result.right.deleted ? " (Deleted)" : ""); new Date(this.result.right.mtime).toLocaleString() + (this.result.right.deleted ? " (Deleted)" : "");
div2.innerHTML = `<span class='deleted'><span class='conflict-dev-name'>${this.localName}</span>: ${date1}</span><br> this.appendVersionInfo(div2, "deleted", this.localName, date1);
<span class='added'><span class='conflict-dev-name'>${this.remoteName}</span>: ${date2}</span><br>`; this.appendVersionInfo(div2, "added", this.remoteName, date2);
contentEl.createEl("button", { text: `Use ${this.localName}` }, (e) => contentEl.createEl("button", { text: `Use ${this.localName}` }, (e) =>
e.addEventListener("click", () => this.sendResponse(this.result.right.rev)) e.addEventListener("click", () => this.sendResponse(this.result.right.rev))
).style.marginRight = "4px"; ).style.marginRight = "4px";
@@ -108,11 +122,9 @@ export class ConflictResolveModal extends Modal {
contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) =>
e.addEventListener("click", () => this.sendResponse(CANCELLED)) e.addEventListener("click", () => this.sendResponse(CANCELLED))
).style.marginRight = "4px"; ).style.marginRight = "4px";
diff = diff.replace(/\n/g, "<br>"); if (diffLength > 100 * 1024) {
if (diff.length > 100 * 1024) { div.empty();
div.innerText = "(Too large diff to display)"; div.innerText = "(Too large diff to display)";
} else {
div.innerHTML = diff;
} }
} }

View File

@@ -43,10 +43,13 @@ export function paneChangeLog(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElem
// tmpDiv.addClass("sls-header-button"); // tmpDiv.addClass("sls-header-button");
tmpDiv.addClass("op-warn-info"); tmpDiv.addClass("op-warn-info");
tmpDiv.innerHTML = `<p>${$msg("obsidianLiveSyncSettingTab.msgNewVersionNote")}</p><button>${$msg("obsidianLiveSyncSettingTab.optionOkReadEverything")}</button>`; tmpDiv.createEl("p", { text: $msg("obsidianLiveSyncSettingTab.msgNewVersionNote") });
const readEverythingButton = tmpDiv.createEl("button", {
text: $msg("obsidianLiveSyncSettingTab.optionOkReadEverything"),
});
if (lastVersion > (this.editingSettings?.lastReadUpdates || 0)) { if (lastVersion > (this.editingSettings?.lastReadUpdates || 0)) {
const informationButtonDiv = informationDivEl.appendChild(tmpDiv); const informationButtonDiv = informationDivEl.appendChild(tmpDiv);
informationButtonDiv.querySelector("button")?.addEventListener("click", () => { readEverythingButton.addEventListener("click", () => {
fireAndForget(async () => { fireAndForget(async () => {
this.editingSettings.lastReadUpdates = lastVersion; this.editingSettings.lastReadUpdates = lastVersion;
await this.saveAllDirtySettings(); await this.saveAllDirtySettings();

View File

@@ -121,13 +121,13 @@ export function paneSetup(
const repo = "vrtmrz/obsidian-livesync"; const repo = "vrtmrz/obsidian-livesync";
const topPath = $msg("obsidianLiveSyncSettingTab.linkTroubleshooting"); const topPath = $msg("obsidianLiveSyncSettingTab.linkTroubleshooting");
const rawRepoURI = `https://raw.githubusercontent.com/${repo}/main`; const rawRepoURI = `https://raw.githubusercontent.com/${repo}/main`;
this.createEl( this.createEl(paneEl, "div", "", (el) => {
paneEl, el.createEl("a", { text: $msg("obsidianLiveSyncSettingTab.linkOpenInBrowser") }, (anchor) => {
"div", anchor.href = `https://github.com/${repo}/blob/main${topPath}`;
"", anchor.target = "_blank";
(el) => anchor.rel = "noopener";
(el.innerHTML = `<a href='https://github.com/${repo}/blob/main${topPath}' target="_blank">${$msg("obsidianLiveSyncSettingTab.linkOpenInBrowser")}</a>`) });
); });
const troubleShootEl = this.createEl(paneEl, "div", { const troubleShootEl = this.createEl(paneEl, "div", {
text: "", text: "",
cls: "sls-troubleshoot-preview", cls: "sls-troubleshoot-preview",

View File

@@ -13,7 +13,7 @@ export const checkConfig = async (
Logger($msg("obsidianLiveSyncSettingTab.logCheckingDbConfig"), LOG_LEVEL_INFO); Logger($msg("obsidianLiveSyncSettingTab.logCheckingDbConfig"), LOG_LEVEL_INFO);
let isSuccessful = true; let isSuccessful = true;
const emptyDiv = createDiv(); const emptyDiv = createDiv();
emptyDiv.innerHTML = "<span></span>"; emptyDiv.createSpan();
checkResultDiv?.replaceChildren(...[emptyDiv]); checkResultDiv?.replaceChildren(...[emptyDiv]);
const addResult = (msg: string, classes?: string[]) => { const addResult = (msg: string, classes?: string[]) => {
const tmpDiv = createDiv(); const tmpDiv = createDiv();
@@ -21,7 +21,7 @@ export const checkConfig = async (
if (classes) { if (classes) {
tmpDiv.addClasses(classes); tmpDiv.addClasses(classes);
} }
tmpDiv.innerHTML = `${msg}`; tmpDiv.textContent = msg;
checkResultDiv?.appendChild(tmpDiv); checkResultDiv?.appendChild(tmpDiv);
}; };
try { try {
@@ -47,9 +47,10 @@ export const checkConfig = async (
if (!checkResultDiv) return; if (!checkResultDiv) return;
const tmpDiv = createDiv(); const tmpDiv = createDiv();
tmpDiv.addClass("ob-btn-config-fix"); tmpDiv.addClass("ob-btn-config-fix");
tmpDiv.innerHTML = `<label>${title}</label><button>${$msg("obsidianLiveSyncSettingTab.btnFix")}</button>`; tmpDiv.createEl("label", { text: title });
const fixButton = tmpDiv.createEl("button", { text: $msg("obsidianLiveSyncSettingTab.btnFix") });
const x = checkResultDiv.appendChild(tmpDiv); const x = checkResultDiv.appendChild(tmpDiv);
x.querySelector("button")?.addEventListener("click", () => { fixButton.addEventListener("click", () => {
fireAndForget(async () => { fireAndForget(async () => {
Logger($msg("obsidianLiveSyncSettingTab.logCouchDbConfigSet", { title, key, value })); Logger($msg("obsidianLiveSyncSettingTab.logCouchDbConfigSet", { title, key, value }));
const res = await requestToCouchDBWithCredentials( const res = await requestToCouchDBWithCredentials(

View File

@@ -4,10 +4,10 @@
import Decision from "@/lib/src/UI/components/Decision.svelte"; import Decision from "@/lib/src/UI/components/Decision.svelte";
import Instruction from "@/lib/src/UI/components/Instruction.svelte"; import Instruction from "@/lib/src/UI/components/Instruction.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte"; import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
const TYPE_CLOSE = "close"; const TYPE_CLOSE = "close";
type ResultType = typeof TYPE_CLOSE; type ResultType = typeof TYPE_CLOSE;
type Props = { type Props = {
setResult: (result: ResultType) => void; setResult: (_result: ResultType) => void;
}; };
const { setResult }: Props = $props(); const { setResult }: Props = $props();
</script> </script>

View File

@@ -3,11 +3,22 @@ import { createServiceFeature } from "@lib/interfaces/ServiceModule";
import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@lib/common/rosetta"; import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@lib/common/rosetta";
import { $msg, setLang } from "@lib/common/i18n"; import { $msg, setLang } from "@lib/common/i18n";
function tryGetLanguage() {
try {
// Note: 1.8.7+ is required. but it is 18, Feb., 2025. we want to fallback on earlier versions, so we catch the error here.
// eslint-disable-next-line obsidianmd/no-unsupported-api
return getLanguage();
} catch (e) {
console.error("Failed to get Obsidian language, defaulting to 'def'", e);
return "en";
}
}
export const enableI18nFeature = createServiceFeature(async ({ services: { setting, API } }) => { export const enableI18nFeature = createServiceFeature(async ({ services: { setting, API } }) => {
let isChanged = false; let isChanged = false;
const settings = setting.currentSettings(); const settings = setting.currentSettings();
if (settings.displayLanguage == "") { if (settings.displayLanguage == "") {
const obsidianLanguage = getLanguage(); const obsidianLanguage = tryGetLanguage();
if ( if (
SUPPORTED_I18N_LANGS.indexOf(obsidianLanguage) !== -1 && // Check if the language is supported SUPPORTED_I18N_LANGS.indexOf(obsidianLanguage) !== -1 && // Check if the language is supported
obsidianLanguage != settings.displayLanguage // Check if the language is different from the current setting obsidianLanguage != settings.displayLanguage // Check if the language is different from the current setting

View File

@@ -5,6 +5,7 @@ import { type UseP2PReplicatorResult } from "@/lib/src/replication/trystero/UseP
import { P2PLogCollector } from "@/lib/src/replication/trystero/P2PLogCollector"; import { P2PLogCollector } from "@/lib/src/replication/trystero/P2PLogCollector";
import { P2PReplicatorPaneView, VIEW_TYPE_P2P } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneView"; import { P2PReplicatorPaneView, VIEW_TYPE_P2P } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneView";
import type { LiveSyncCore } from "@/main"; import type { LiveSyncCore } from "@/main";
import type { WorkspaceLeaf } from "@/deps";
/** /**
* ServiceFeature: P2P Replicator lifecycle management. * ServiceFeature: P2P Replicator lifecycle management.
@@ -43,7 +44,7 @@ export function useP2PReplicatorUI(
// Register view, commands and ribbon if a view factory is provided // Register view, commands and ribbon if a view factory is provided
const viewType = VIEW_TYPE_P2P; const viewType = VIEW_TYPE_P2P;
const factory = (leaf: any) => { const factory = (leaf: WorkspaceLeaf) => {
return new P2PReplicatorPaneView(leaf, core, { return new P2PReplicatorPaneView(leaf, core, {
replicator: getReplicator(), replicator: getReplicator(),
p2pLogCollector, p2pLogCollector,

View File

@@ -38,10 +38,20 @@ export class ObsidianVaultAdapter implements IVaultAdapter<TFile> {
} }
async delete(file: TFile | TFolder, force = false): Promise<void> { async delete(file: TFile | TFolder, force = false): Promise<void> {
// if ("trashFile" in this.app.fileManager) {
// // eslint-disable-next-line obsidianmd/no-unsupported-api
// return await this.app.fileManager.trashFile(file);
// }
//TODO: need fix
return await this.app.vault.delete(file, force); return await this.app.vault.delete(file, force);
} }
async trash(file: TFile | TFolder, force = false): Promise<void> { async trash(file: TFile | TFolder, force = false): Promise<void> {
// if ("trashFile" in this.app.fileManager) {
// // eslint-disable-next-line obsidianmd/no-unsupported-api
// return await this.app.fileManager.trashFile(file);
// }
//TODO: need fix
return await this.app.vault.trash(file, force); return await this.app.vault.trash(file, force);
} }

View File

@@ -0,0 +1,35 @@
import type { Locator, Page } from "playwright";
import { type ObsidianHandle, launchObsidian } from "./obsidian";
import { type VaultSettingsOptions, type VaultSetupResult, setupTestVaultWithSettings } from "./vault";
// ---------------------------------------------------------------------------
// Helpers (vault setup, test scaffolding, etc.)
// ---------------------------------------------------------------------------
export async function withSeededVault(
options: VaultSettingsOptions,
run: (context: { app: ObsidianHandle; vault: VaultSetupResult }) => Promise<void>
): Promise<void> {
const vault = setupTestVaultWithSettings(options);
const app = await launchObsidian(vault.fakeAppData, vault.vaultDir);
try {
await run({ app, vault });
} finally {
await app.close().catch(() => {});
vault.cleanup();
}
}
// ---------------------------------------------------------------------------
// Selectors
// ---------------------------------------------------------------------------
/** CSS selector for the settings-tab content area. */
export const SELECTOR_SETTINGS_CONTENT = ".vertical-tab-content-container";
/** CSS selector for Obsidian notice toasts. */
export const SELECTOR_NOTICE = ".notice-container .notice";
export function locateModalByTitle(page: Page, title: string): Locator {
return page.locator(".modal-container .modal-title").filter({ hasText: title });
}

View File

@@ -0,0 +1,294 @@
/* eslint-disable obsidianmd/prefer-window-timers */
/* eslint-disable import/no-nodejs-modules */
/* eslint-disable import/no-extraneous-dependencies */
/**
* helpers/obsidian.ts
*
* Launch / teardown helpers for the Obsidian Electron application and
* common UI interactions needed across test files.
*
* Launch strategy
* ---------------
* Playwright's `_electron.launch()` cannot reliably connect to Obsidian.exe
* via CDP because Obsidian's startup sequence does not expose the DevTools
* URL on stdout/stderr in a way Playwright can detect. Instead, we:
* 1. Spawn Obsidian with a fixed `--remote-debugging-port`.
* 2. Poll `http://127.0.0.1:<port>/json/version` until the port is ready.
* 3. Connect with `chromium.connectOverCDP()`.
*/
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import http from "node:http";
import path from "node:path";
import os from "node:os";
import type { Browser, Page } from "playwright";
import type { ChildProcess } from "node:child_process";
import process from "node:process";
import { enablePlugin, isPluginEnabled } from "./obsidianFunctions";
// ---------------------------------------------------------------------------
// Executable path resolution
// ---------------------------------------------------------------------------
function defaultObsidianPath(): string {
switch (os.platform()) {
case "win32":
return path.join(os.homedir(), "AppData", "Local", "Obsidian", "Obsidian.exe");
case "darwin":
return "/Applications/Obsidian.app/Contents/MacOS/Obsidian";
default:
return process.env["OBSIDIAN_PATH"] ?? "/usr/bin/obsidian";
}
}
/**
* Path to the Obsidian executable.
* Override with the `OBSIDIAN_PATH` environment variable if needed.
*/
export const OBSIDIAN_EXECUTABLE: string = process.env["OBSIDIAN_PATH"] ?? defaultObsidianPath();
/** Fixed CDP port used for all test runs (workers: 1, so no collisions). */
const CDP_PORT = 19222;
// ---------------------------------------------------------------------------
// Launch
// ---------------------------------------------------------------------------
/**
* Handle returned by `launchObsidian`. Provides just enough surface to drive
* the Obsidian window and shut it down cleanly.
*/
export interface ObsidianHandle {
/** Returns the main Obsidian renderer page. */
firstWindow(): Promise<Page>;
/** Closes the CDP connection and kills the Obsidian process. */
close(): Promise<void>;
}
/** Poll `http://127.0.0.1:<port>/json/version` until Obsidian is ready. */
async function waitForCDP(port: number, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const ready = await new Promise<boolean>((resolve) => {
const req = http.get(`http://127.0.0.1:${port}/json/version`, (res: http.IncomingMessage) => {
res.resume();
resolve(res.statusCode === 200);
});
req.on("error", () => resolve(false));
req.setTimeout(1_000, () => {
req.destroy();
resolve(false);
});
});
if (ready) return;
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`Obsidian CDP port ${port} was not ready within ${timeoutMs}ms`);
}
/**
* Launches Obsidian with an isolated user-data directory and opens the
* given vault via the `obsidian://open` URI scheme.
*
* Uses a fixed `--remote-debugging-port` so we can poll and connect via
* `chromium.connectOverCDP()` without relying on Playwright's electron
* startup detection, which does not work with Obsidian.exe.
*/
export async function launchObsidian(fakeAppData: string, vaultDir: string): Promise<ObsidianHandle> {
const proc: ChildProcess = spawn(
OBSIDIAN_EXECUTABLE,
[
`--remote-debugging-port=${CDP_PORT}`,
`--user-data-dir=${fakeAppData}`,
"--no-sandbox",
"--lang=en",
`obsidian://open?path=${encodeURIComponent(vaultDir)}`,
],
{ env: { ...process.env, LIBGL_ALWAYS_SOFTWARE: "1" } }
);
proc.on("error", (err: Error) => {
console.error("[launchObsidian] spawn error:", err.message);
});
await waitForCDP(CDP_PORT, 60_000);
const browser: Browser = await chromium.connectOverCDP(`http://127.0.0.1:${CDP_PORT}`);
const waitForProcessExit = async (): Promise<void> => {
if (proc.exitCode !== null || proc.killed) {
return;
}
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
proc.removeListener("exit", onExit);
proc.removeListener("close", onExit);
resolve();
}, 5_000);
const onExit = () => {
clearTimeout(timer);
proc.removeListener("exit", onExit);
proc.removeListener("close", onExit);
resolve();
};
proc.once("exit", onExit);
proc.once("close", onExit);
});
};
return {
close: async () => {
try {
await browser.close();
} catch {
/* ignore */
}
try {
proc.kill();
} catch {
/* ignore */
}
await waitForProcessExit();
},
firstWindow: async (): Promise<Page> => {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
for (const ctx of browser.contexts()) {
const pages = ctx.pages().filter((p: Page) => !p.isClosed());
if (pages.length > 0) return pages[0];
}
await new Promise((r) => setTimeout(r, 300));
}
throw new Error("No Obsidian window found after 30s");
},
};
}
// ---------------------------------------------------------------------------
// Window helpers
// ---------------------------------------------------------------------------
/**
* Returns the main Obsidian window and waits for its DOM to be ready.
*/
export async function getMainWindow(app: ObsidianHandle): Promise<Page> {
const page = await app.firstWindow();
await page.waitForLoadState("domcontentloaded", { timeout: 30_000 });
return page;
}
/**
* Waits until the Obsidian vault workspace has finished loading.
*
* Handles the 'Trust author and enable plugins' prompt and the
* community-plugins information modal that appear on a first-time vault open.
*/
export async function waitForVaultReady(page: Page): Promise<void> {
// Trust prompt — must be dismissed before the workspace renders.
const trustButton = page.getByRole("button", { name: /trust author and enable plugins/i });
try {
await trustButton.waitFor({ state: "visible", timeout: 15_000 });
await trustButton.click();
await page.waitForTimeout(1_500);
} catch {
// Not shown — vault already trusted or safe mode off.
}
// Once the trust prompt is handled, then the plugin dialogues may appear. Wait a bit for them to show up and log them if they do, to help diagnose blocked flows.
// await page.waitForTimeout(100);
// Community-plugins modal — dismiss with Escape.
try {
const modal = page.locator(".modal-container").filter({ hasText: /community plugins/i });
await modal.waitFor({ state: "visible", timeout: 5_000 });
await page.keyboard.press("Escape");
await page.waitForTimeout(10);
} catch {
// Modal not shown.
}
await page.waitForSelector(".workspace-ribbon", { timeout: 60_000 });
}
export async function enablePluginInObsidian(page: Page, pluginName: string) {
const handled = await page.evaluateHandle(enablePlugin, pluginName);
return handled;
}
export function isPluginEnabledInObsidian(page: Page, pluginName: string): Promise<boolean> {
const handled = page.evaluate(isPluginEnabled, pluginName);
return handled;
}
// ---------------------------------------------------------------------------
// Settings modal helpers
// ---------------------------------------------------------------------------
/**
* Opens the Obsidian Settings modal via the standard keyboard shortcut and
* waits for the navigation panel to become visible.
*/
export async function openSettings(page: Page): Promise<void> {
await page.keyboard.press("Control+,");
await page.waitForSelector(".modal-container .vertical-tab-nav-item", { timeout: 15_000 });
}
/**
* Clicks a settings navigation tab identified by its visible text label.
*/
export async function clickSettingsTab(page: Page, label: string): Promise<void> {
const tab = page.locator(".vertical-tab-nav-item", { hasText: label });
await tab.first().click();
await page.waitForTimeout(300);
}
/**
* Opens Settings and navigates directly to the Self-hosted LiveSync tab.
*/
export async function openLiveSyncSettings(page: Page): Promise<void> {
await openSettings(page);
await clickSettingsTab(page, "Self-hosted LiveSync");
}
/**
* Logs visible modal/dialog-like UI elements to help diagnose blocked flows.
*/
export async function logVisibleDialogs(page: Page, label = "dialogs"): Promise<void> {
const summaries = await page
.locator(".modal-container, [role='dialog'], .notice-container .notice")
.evaluateAll((nodes) => {
return nodes
.map((node) => {
const element = node as HTMLElement;
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
const visible =
style.display !== "none" &&
style.visibility !== "hidden" &&
rect.width > 0 &&
rect.height > 0 &&
!!element.textContent?.trim();
if (!visible) {
return undefined;
}
return {
classes: element.className,
text: element.textContent?.replace(/\s+/g, " ").trim().slice(0, 240) ?? "",
};
})
.filter((item): item is { classes: string; text: string } => !!item);
});
if (summaries.length === 0) {
console.log(`[obsidian:${label}] no visible dialogs`);
return;
}
for (const [index, summary] of summaries.entries()) {
console.log(`[obsidian:${label}] #${index + 1} class=${summary.classes} text=${summary.text}`);
}
}

View File

@@ -0,0 +1,19 @@
/* eslint-disable no-restricted-globals */
import type { App } from "obsidian";
declare global {
var app: App & {
plugins: {
enabledPlugins: Set<string>;
enablePlugin: (name: string) => Promise<void>;
};
};
}
export const enablePlugin = async (pluginName: string) => {
return await window.app.plugins.enablePlugin(pluginName);
};
export const isPluginEnabled = (pluginName: string) => {
return window.app.plugins.enabledPlugins.has(pluginName);
};

165
test_e2e/helpers/vault.ts Normal file
View File

@@ -0,0 +1,165 @@
/* eslint-disable obsidianmd/prefer-window-timers */
// This file is a test helper and is allowed to use Node.js modules.
/* eslint-disable obsidianmd/hardcoded-config-path */
// This file is a test helper and is allowed to use Node.js modules.
/* eslint-disable import/no-nodejs-modules */
/**
* helpers/vault.ts
*
* Creates a fully-isolated, throwaway Obsidian vault for each test run.
*
* Directory layout produced by `setupTestVault()`:
*
* <tmpdir>/livesync-e2e-<id>/
* obsidian.json <- registered vault list (Obsidian userData config)
* vault/
* .obsidian/
* app.json <- safe-mode disabled
* community-plugins.json
* plugins/
* obsidian-livesync/
* main.js <- built plugin (copied from repo root)
* manifest.json
* styles.css
*/
import { copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { randomBytes } from "node:crypto";
import path from "node:path";
import os from "node:os";
/** Absolute path to the repository root (two levels above helpers/). */
// eslint-disable-next-line no-undef
const REPO_ROOT = path.resolve(__dirname, "../..");
export interface VaultSetupResult {
/** The vault directory that Obsidian will open. */
vaultDir: string;
/**
* The directory used as `--user-data-dir` for the Obsidian process.
* Obsidian reads its vault registry from `<fakeAppData>/obsidian.json`.
*/
fakeAppData: string;
/** Removes the entire temporary tree. */
cleanup: () => void;
}
export interface VaultSettingsOptions {
/** Optional custom app.json content under <vault>/.obsidian/app.json */
appJson?: Record<string, unknown>;
/** Community plugin IDs to mark as enabled. */
communityPlugins?: string[];
/** Per-plugin configuration keyed by plugin ID. */
pluginData?: Record<string, unknown>;
}
/**
* Creates a throw-away vault with the built plugin pre-installed and
* registered in an isolated Obsidian configuration directory.
*
* Call `cleanup()` (or use `test.afterAll`) to delete the temporary files.
*/
export function setupTestVault(): VaultSetupResult {
return setupTestVaultWithSettings({});
}
/**
* Creates a throw-away vault with optional initial Obsidian/plugin settings.
*
* This helper is intended for real-Obsidian e2e tests that need to open a
* vault in a known configuration state.
*/
export function setupTestVaultWithSettings(options: VaultSettingsOptions = {}): VaultSetupResult {
const id = randomBytes(4).toString("hex");
const baseDir = path.join(os.tmpdir(), `livesync-e2e-${id}`);
const fakeAppData = baseDir;
const vaultDir = path.join(baseDir, "vault");
// ------------------------------------------------------------------ vault
const dotObsidian = path.join(vaultDir, ".obsidian");
const pluginDir = path.join(dotObsidian, "plugins", "obsidian-livesync");
mkdirSync(pluginDir, { recursive: true });
// Copy the built plugin artefacts from the repository root.
for (const file of ["main.js", "manifest.json", "styles.css"]) {
const src = path.join(REPO_ROOT, file);
if (existsSync(src)) {
copyFileSync(src, path.join(pluginDir, file));
} else {
console.warn(`[vault setup] Expected file not found: ${src}`);
}
}
// Disable Obsidian safe mode so community plugins are allowed to load.
writeFileSync(
path.join(dotObsidian, "app.json"),
JSON.stringify({ promptDelete: false, ...(options.appJson ?? {}) }, null, 2),
"utf-8"
);
// Tell Obsidian which community plugins are enabled.
writeFileSync(
path.join(dotObsidian, "community-plugins.json"),
// JSON.stringify(options.communityPlugins ?? ["obsidian-livesync"], null, 2),
// You should enable the plugin(s) explicitly
JSON.stringify(options.communityPlugins ?? [], null, 2),
"utf-8"
);
if (options.pluginData) {
for (const [pluginId, value] of Object.entries(options.pluginData)) {
const target = path.join(dotObsidian, "plugins", pluginId, "data.json");
mkdirSync(path.dirname(target), { recursive: true });
writeFileSync(target, JSON.stringify(value, null, 2), "utf-8");
}
}
// ------------------------------------------------ Obsidian global config
// With --user-data-dir=<fakeAppData>, Obsidian reads its vault registry
// directly from <fakeAppData>/obsidian.json.
mkdirSync(fakeAppData, { recursive: true });
const vaultId = randomBytes(8).toString("hex");
writeFileSync(
path.join(fakeAppData, "obsidian.json"),
JSON.stringify(
{
vaults: {
[vaultId]: {
path: vaultDir,
ts: Date.now(),
open: true,
},
},
updateDisabled: true,
},
null,
2
),
"utf-8"
);
return {
vaultDir,
fakeAppData,
cleanup: () =>
void (async () => {
for (let attempt = 1; attempt <= 5; attempt++) {
try {
rmSync(baseDir, { recursive: true, force: true });
console.log(`[vault cleanup] Successfully removed temporary directory: ${baseDir}`);
return;
} catch {
console.warn(
`[vault cleanup] Attempt ${attempt} failed to remove temporary directory: ${baseDir}`
);
await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
}
}
console.error(
`[vault cleanup] Failed to remove temporary directory after multiple attempts: ${baseDir}`
);
})(),
};
}

View File

@@ -0,0 +1,3 @@
// Example wrapper for Playwright test functions and assertions, this file is not used in Self-hosted LiveSync.
// eslint-disable-next-line import/no-extraneous-dependencies
export { test, expect } from "playwright/test";

3
test_e2e/package.json Normal file
View File

@@ -0,0 +1,3 @@
{
"type": "commonjs"
}

View File

@@ -0,0 +1,24 @@
import { defineConfig } from "playwright/test";
import path from "node:path";
export default defineConfig({
testDir: path.join(__dirname, "tests"),
outputDir: path.join(__dirname, "test-results"),
// Each test may need to cold-start Obsidian and wait for the vault to load.
timeout: 120_000,
expect: { timeout: 20_000 },
// Tests are stateful (one Obsidian process per test file), so no parallelism.
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [["list"], ["html", { open: "never", outputFolder: path.join(__dirname, "playwright-report") }]],
use: {
// Artefacts are kept only when a test fails.
screenshot: "only-on-failure",
video: "retain-on-failure",
trace: "retain-on-failure",
},
});

View File

@@ -0,0 +1,69 @@
/**
* tests/sample.spec.ts
*
* Example e2e test that opens a vault with pre-seeded settings.
*/
import {
getMainWindow,
waitForVaultReady,
enablePluginInObsidian,
isPluginEnabledInObsidian,
} from "../helpers/obsidian";
import type { ObsidianLiveSyncSettings } from "@lib/common/types";
import { PartialMessages } from "@lib/common/messages/def";
import { locateModalByTitle, withSeededVault } from "test_e2e/helpers/helpers";
import { test, expect } from "test_e2e/helpers/wrapper";
const def = PartialMessages.def;
test("show Welcome when isConfigured is false", async () => {
await withSeededVault(
{
appJson: {
promptDelete: false,
},
communityPlugins: [],
pluginData: {
"obsidian-livesync": {
deviceAndVaultName: "e2e-configured-device",
isConfigured: true,
notifyThresholdOfRemoteStorageSize: 10000,
} satisfies Partial<ObsidianLiveSyncSettings>,
},
},
async ({ app }) => {
const page = await getMainWindow(app);
await waitForVaultReady(page);
await expect(enablePluginInObsidian(page, "obsidian-livesync")).resolves.not.toThrow();
expect(isPluginEnabledInObsidian(page, "obsidian-livesync")).toBeTruthy();
const welcome = locateModalByTitle(page, def["moduleMigration.titleWelcome"]);
await expect(welcome).toBeHidden({ timeout: 1_000 });
}
);
});
test("does not show Welcome when isConfigured is true", async () => {
await withSeededVault(
{
appJson: {
promptDelete: false,
},
communityPlugins: [],
pluginData: {
"obsidian-livesync": {
deviceAndVaultName: "e2e-configured-device",
isConfigured: true,
notifyThresholdOfRemoteStorageSize: 10000,
} satisfies Partial<ObsidianLiveSyncSettings>,
},
},
async ({ app }) => {
const page = await getMainWindow(app);
await waitForVaultReady(page);
await expect(enablePluginInObsidian(page, "obsidian-livesync")).resolves.not.toThrow();
expect(isPluginEnabledInObsidian(page, "obsidian-livesync")).toBeTruthy();
const welcome = locateModalByTitle(page, def["moduleMigration.titleWelcome"]);
await expect(welcome).toBeHidden({ timeout: 1_000 });
}
);
});

View File

@@ -3,7 +3,23 @@ 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. 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.62
14th May, 2026
### Fixed
- Fixed an issue where a connection could not be established when attempting to connect to a brand-new remote database without going through the set-up wizard or configuration checking (#660).
## 0.25.61
13th May, 2026
Reviews have started on the Obsidian Community, haven't they? It was quite a struggle, what with having to fix the outdated ESLint.
I am a bit nervous, but it is far better than just plodding along aimlessly, so let us get on with it. If you spot any issues, please let me know straight away.
From now on, I am avoiding committing directly to the main branch. This is because you lots have all been sending so much PRs. I wanted to keep things harmonious.
That said, I am still not used to rebasing, so there are some parts where the commit history is a right mess. I will work on improving that.
### Improved ### Improved
@@ -14,6 +30,29 @@ This P2P synchronisation is not compatible with previous versions in terms of co
### Fixed ### Fixed
- No longer baffling errors occur when setting-update is triggered during the early stage of initialisation. - No longer baffling errors occur when setting-update is triggered during the early stage of initialisation.
- Network error notice pop-ups are now suppressed when 'NetworkWarningStyle' is set to 'Hidden'. (Thank you so much @SeleiXi!)
### New features
- Diff navigation buttons have been added to the diff view, making it easier to move between differences. (Thank you so much @SeleiXi! #871)
### Translations
- Chinese (Simplified) translations for settings and the Setup Wizard have been added. (Thank you so much @zombiek731!)
- Common UI controls and signal words are now localised into Chinese (Simplified). (Thank you so much @zombiek731!)
- i18n runtime behaviour and locale coverage have been improved. (Thank you so much @52sanmao!)
### CLI
#### New features
- Daemon synchronisation is now supported. (Thank you so much @andrewleech! #843)
- `HeadlessConfirm` has been implemented with sensible defaults, enabling unattended operation in headless environments. (Thank you so much @andrewleech!)
- The CLI onboarding experience has been improved. (Thank you so much @OriBoharon! #872)
#### Fixed
- Sub-millisecond CLI mtimes are now truncated to prevent mobile crash. (Thank you so much @brian-spackman! #893)
## 0.25.60 ## 0.25.60

17
version-bump.mjs Normal file
View File

@@ -0,0 +1,17 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json with target version and minAppVersion from manifest.json
// but only if the target version is not already in versions.json
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
if (!Object.values(versions).includes(minAppVersion)) {
versions[targetVersion] = minAppVersion;
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
}

View File

@@ -1,4 +1,6 @@
{ {
"0.25.61": "1.7.2",
"0.25.60": "1.7.2",
"1.0.1": "0.9.12", "1.0.1": "0.9.12",
"1.0.0": "0.9.7" "1.0.0": "0.9.7"
} }

View File

@@ -2,7 +2,8 @@ import { defineConfig, mergeConfig } from "vitest/config";
import { playwright } from "@vitest/browser-playwright"; import { playwright } from "@vitest/browser-playwright";
import viteConfig from "./vitest.config.common"; import viteConfig from "./vitest.config.common";
import path from "path"; import path from "path";
import dotenv from "dotenv"; import { existsSync, readFileSync } from "node:fs";
import { parseEnv } from "node:util";
import { grantClipboardPermissions, writeHandoffFile, readHandoffFile } from "./test/lib/commands"; import { grantClipboardPermissions, writeHandoffFile, readHandoffFile } from "./test/lib/commands";
// P2P test environment variables // P2P test environment variables
@@ -22,8 +23,9 @@ import { grantClipboardPermissions, writeHandoffFile, readHandoffFile } from "./
// General test options (also read from env): // General test options (also read from env):
// ENABLE_DEBUGGER - Set to "true" to attach a debugger and pause before tests // ENABLE_DEBUGGER - Set to "true" to attach a debugger and pause before tests
// ENABLE_UI - Set to "true" to open a visible browser window during tests // ENABLE_UI - Set to "true" to open a visible browser window during tests
const defEnv = dotenv.config({ path: ".env" }).parsed; const loadEnvFile = (path: string) => (existsSync(path) ? parseEnv(readFileSync(path, "utf-8")) : undefined);
const testEnv = dotenv.config({ path: ".test.env" }).parsed; const defEnv = loadEnvFile(".env");
const testEnv = loadEnvFile(".test.env");
// Merge: dotenv files < process.env (so shell-injected vars like P2P_TEST_* take precedence) // Merge: dotenv files < process.env (so shell-injected vars like P2P_TEST_* take precedence)
const p2pEnv: Record<string, string> = {}; const p2pEnv: Record<string, string> = {};
if (process.env.P2P_TEST_ROOM_ID) p2pEnv.P2P_TEST_ROOM_ID = process.env.P2P_TEST_ROOM_ID; if (process.env.P2P_TEST_ROOM_ID) p2pEnv.P2P_TEST_ROOM_ID = process.env.P2P_TEST_ROOM_ID;

View File

@@ -2,10 +2,13 @@ import { defineConfig, mergeConfig } from "vitest/config";
import { playwright } from "@vitest/browser-playwright"; import { playwright } from "@vitest/browser-playwright";
import viteConfig from "./vitest.config.common"; import viteConfig from "./vitest.config.common";
import path from "path"; import path from "path";
import dotenv from "dotenv"; import { existsSync, readFileSync } from "node:fs";
import { parseEnv } from "node:util";
import { grantClipboardPermissions, openWebPeer, closeWebPeer, acceptWebPeer } from "./test/lib/commands"; import { grantClipboardPermissions, openWebPeer, closeWebPeer, acceptWebPeer } from "./test/lib/commands";
const defEnv = dotenv.config({ path: ".env" }).parsed;
const testEnv = dotenv.config({ path: ".test.env" }).parsed; const loadEnvFile = (path: string) => (existsSync(path) ? parseEnv(readFileSync(path, "utf-8")) : undefined);
const defEnv = loadEnvFile(".env");
const testEnv = loadEnvFile(".test.env");
const env = Object.assign({}, defEnv, testEnv); const env = Object.assign({}, defEnv, testEnv);
const debuggerEnabled = env?.ENABLE_DEBUGGER === "true"; const debuggerEnabled = env?.ENABLE_DEBUGGER === "true";
const enableUI = env?.ENABLE_UI === "true"; const enableUI = env?.ENABLE_UI === "true";