mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-11 18:21:50 +00:00
Compare commits
14 Commits
feat-userh
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6b153c0de | ||
|
|
eca6a6e0ba | ||
|
|
ca43d96c46 | ||
|
|
112e3c8b1d | ||
|
|
d1eb105801 | ||
|
|
d5b93e89cd | ||
|
|
e96fe7cde1 | ||
|
|
68e0610f1d | ||
|
|
772b6ecf26 | ||
|
|
81dc7f604b | ||
|
|
a9c87fa52e | ||
|
|
e81f023943 | ||
|
|
7a4b76a550 | ||
|
|
f9294446ba |
2
.github/ISSUE_TEMPLATE/issue-report.md
vendored
2
.github/ISSUE_TEMPLATE/issue-report.md
vendored
@@ -2,7 +2,7 @@
|
|||||||
name: Issue report
|
name: Issue report
|
||||||
about: Create a report to help us improve
|
about: Create a report to help us improve
|
||||||
title: ''
|
title: ''
|
||||||
labels: 'bug'
|
labels: 'uncategorised'
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
41
.github/workflows/cli-deno-tests.yml
vendored
41
.github/workflows/cli-deno-tests.yml
vendored
@@ -17,9 +17,48 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
task_matrix: ${{ steps.select.outputs.task_matrix }}
|
||||||
|
steps:
|
||||||
|
- name: Select task matrix
|
||||||
|
id: select
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SELECTED_TASK="${{ github.event_name == 'workflow_dispatch' && inputs.test_task || 'test' }}"
|
||||||
|
echo "[INFO] Selected task set: $SELECTED_TASK"
|
||||||
|
|
||||||
|
case "$SELECTED_TASK" in
|
||||||
|
test)
|
||||||
|
TASK_MATRIX='["test:setup-put-cat","test:mirror","test:push-pull","test:sync-two-local","test:sync-locked-remote","test:p2p-host","test:p2p-peers","test:p2p-sync","test:p2p-three-nodes","test:p2p-upload-download","test:e2e-couchdb","test:e2e-matrix"]'
|
||||||
|
;;
|
||||||
|
test:local)
|
||||||
|
TASK_MATRIX='["test:setup-put-cat","test:mirror"]'
|
||||||
|
;;
|
||||||
|
test:e2e-matrix)
|
||||||
|
TASK_MATRIX='["test:e2e-matrix"]'
|
||||||
|
;;
|
||||||
|
test:p2p-sync)
|
||||||
|
TASK_MATRIX='["test:p2p-sync"]'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "[ERROR] Unknown task set: $SELECTED_TASK" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "task_matrix=$TASK_MATRIX" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
test:
|
test:
|
||||||
|
needs: prepare
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
task: ${{ fromJson(needs.prepare.outputs.task_matrix) }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -64,7 +103,7 @@ jobs:
|
|||||||
LIVESYNC_DOCKER_MODE: native
|
LIVESYNC_DOCKER_MODE: native
|
||||||
LIVESYNC_CLI_RETRY: 3
|
LIVESYNC_CLI_RETRY: 3
|
||||||
run: |
|
run: |
|
||||||
TASK="${{ github.event_name == 'workflow_dispatch' && inputs.test_task || 'test' }}"
|
TASK="${{ matrix.task }}"
|
||||||
echo "[INFO] Running Deno task: $TASK"
|
echo "[INFO] Running Deno task: $TASK"
|
||||||
deno task "$TASK"
|
deno task "$TASK"
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const prettierConfig = {
|
|||||||
tabWidth: 4,
|
tabWidth: 4,
|
||||||
printWidth: 120,
|
printWidth: 120,
|
||||||
semi: true,
|
semi: true,
|
||||||
endOfLine: "cr",
|
endOfLine: "lf",
|
||||||
...localPrettierConfig,
|
...localPrettierConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
3
devs.md
3
devs.md
@@ -63,6 +63,9 @@ npm test # Run vitest tests (requires Docker services)
|
|||||||
|
|
||||||
### Environment Setup
|
### Environment Setup
|
||||||
|
|
||||||
|
- Clone with submodules: `git clone --recurse-submodules <repository-url>`
|
||||||
|
- If you already cloned without them, run: `git submodule update --init --recursive`
|
||||||
|
- The shared common library is provided by the `src/lib` submodule, and builds will fail if it is missing
|
||||||
- Create `.env` file with `PATHS_TEST_INSTALL` pointing to test vault plug-in directories (`:` separated on Unix, `;` on Windows)
|
- Create `.env` file with `PATHS_TEST_INSTALL` pointing to test vault plug-in directories (`:` separated on Unix, `;` on Windows)
|
||||||
- Development builds auto-copy to these paths on build
|
- Development builds auto-copy to these paths on build
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export default [
|
|||||||
"modules/octagonal-wheels/rollup.config.js",
|
"modules/octagonal-wheels/rollup.config.js",
|
||||||
"modules/octagonal-wheels/dist/**/*",
|
"modules/octagonal-wheels/dist/**/*",
|
||||||
"src/lib/test",
|
"src/lib/test",
|
||||||
|
"src/lib/_tools",
|
||||||
"src/lib/src/cli",
|
"src/lib/src/cli",
|
||||||
"**/main.js",
|
"**/main.js",
|
||||||
"src/apps/**/*",
|
"src/apps/**/*",
|
||||||
|
|||||||
4
src/apps/cli/.gitignore
vendored
4
src/apps/cli/.gitignore
vendored
@@ -3,4 +3,6 @@ test/*
|
|||||||
!test/*.sh
|
!test/*.sh
|
||||||
test/test-init.local.sh
|
test/test-init.local.sh
|
||||||
node_modules
|
node_modules
|
||||||
.*.json
|
.*.json
|
||||||
|
*.env
|
||||||
|
!.test.env
|
||||||
@@ -92,28 +92,39 @@ livesync-cli ./my-db pull folder/note.md ./note.md
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone with submodules, because the shared core lives in src/lib
|
||||||
|
git clone --recurse-submodules <repository-url>
|
||||||
|
cd obsidian-livesync
|
||||||
|
|
||||||
|
# If you already cloned without submodules, run this once instead
|
||||||
|
git submodule update --init --recursive
|
||||||
|
|
||||||
|
# Install dependencies from the repository root
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Build the CLI from its package directory
|
||||||
|
cd src/apps/cli
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
If `src/lib` is missing, `npm run build` now stops early with a targeted message
|
||||||
|
instead of a low-level Vite `ENOENT` error.
|
||||||
|
|
||||||
```bash
|
Run the CLI:
|
||||||
# Install dependencies (ensure you are in repository root directory, not src/apps/cli)
|
|
||||||
# due to shared dependencies with webapp and main library
|
```bash
|
||||||
npm install
|
# Run with npm script (from repository root)
|
||||||
# Build the project (ensure you are in `src/apps/cli` directory)
|
npm run --silent cli -- [database-path] [command] [args...]
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the CLI:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run with npm script (from repository root)
|
|
||||||
npm run --silent cli -- [database-path] [command] [args...]
|
|
||||||
# Run the built executable directly
|
# Run the built executable directly
|
||||||
node src/apps/cli/dist/index.cjs [database-path] [command] [args...]
|
node src/apps/cli/dist/index.cjs [database-path] [command] [args...]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
A Docker image is provided for headless / server deployments. Build from the repository root:
|
A Docker image is provided for headless / server deployments. Build from the repository root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -f src/apps/cli/Dockerfile -t livesync-cli .
|
docker build -f src/apps/cli/Dockerfile -t livesync-cli .
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"prebuild": "node scripts/check-submodule.mjs",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"cli": "node dist/index.cjs",
|
"cli": "node dist/index.cjs",
|
||||||
|
|||||||
36
src/apps/cli/scripts/check-submodule.mjs
Normal file
36
src/apps/cli/scripts/check-submodule.mjs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
const cliDir = process.cwd();
|
||||||
|
const repoRoot = path.resolve(cliDir, "../../..");
|
||||||
|
const requiredFiles = [
|
||||||
|
path.join(repoRoot, "src/lib/src/common/types.ts"),
|
||||||
|
];
|
||||||
|
|
||||||
|
const missingFiles = requiredFiles.filter((filePath) => !fs.existsSync(filePath));
|
||||||
|
|
||||||
|
if (missingFiles.length === 0) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("[CLI Build Error] Required shared sources were not found.");
|
||||||
|
console.error("This repository uses Git submodules, and the CLI depends on src/lib.");
|
||||||
|
console.error("");
|
||||||
|
console.error("Missing file(s):");
|
||||||
|
for (const filePath of missingFiles) {
|
||||||
|
console.error(` - ${path.relative(repoRoot, filePath)}`);
|
||||||
|
}
|
||||||
|
console.error("");
|
||||||
|
console.error("Initialize submodules, then retry the CLI build:");
|
||||||
|
console.error(" git submodule update --init --recursive");
|
||||||
|
console.error("");
|
||||||
|
console.error("For a fresh clone, prefer:");
|
||||||
|
console.error(" git clone --recurse-submodules <repository-url>");
|
||||||
|
console.error("");
|
||||||
|
console.error("Then run:");
|
||||||
|
console.error(" npm install");
|
||||||
|
console.error(" cd src/apps/cli");
|
||||||
|
console.error(" npm run build");
|
||||||
|
|
||||||
|
process.exit(1);
|
||||||
9
src/apps/cli/testdeno/.test.env
Normal file
9
src/apps/cli/testdeno/.test.env
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
hostname=http://127.0.0.1:5989/
|
||||||
|
dbname=livesync-test-db-ci
|
||||||
|
username=admin
|
||||||
|
password=testpassword
|
||||||
|
minioEndpoint=http://127.0.0.1:9000
|
||||||
|
accessKey=minioadmin
|
||||||
|
secretKey=minioadmin
|
||||||
|
bucketName=livesync-test-bucket-ci
|
||||||
|
LIVESYNC_TEST_TEE=1
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"test": "deno test -A --no-check test-*.ts",
|
"test": "deno test --env-file=.test.env -A --no-check test-*.ts",
|
||||||
"test:local": "deno test -A --no-check test-setup-put-cat.ts test-mirror.ts",
|
"test:local": "deno test --env-file=.test.env -A --no-check test-setup-put-cat.ts test-mirror.ts",
|
||||||
"test:push-pull": "deno test -A --no-check test-push-pull.ts",
|
"test:push-pull": "deno test --env-file=.test.env -A --no-check test-push-pull.ts",
|
||||||
"test:setup-put-cat": "deno test -A --no-check test-setup-put-cat.ts",
|
"test:setup-put-cat": "deno test --env-file=.test.env -A --no-check test-setup-put-cat.ts",
|
||||||
"test:mirror": "deno test -A --no-check test-mirror.ts",
|
"test:mirror": "deno test --env-file=.test.env -A --no-check test-mirror.ts",
|
||||||
"test:sync-two-local": "deno test -A --no-check test-sync-two-local-databases.ts",
|
"test:sync-two-local": "deno test --env-file=.test.env -A --no-check test-sync-two-local-databases.ts",
|
||||||
"test:sync-locked-remote": "deno test -A --no-check test-sync-locked-remote.ts",
|
"test:sync-locked-remote": "deno test --env-file=.test.env -A --no-check test-sync-locked-remote.ts",
|
||||||
"test:p2p-host": "deno test -A --no-check test-p2p-host.ts",
|
"test:p2p-host": "deno test --env-file=.test.env -A --no-check test-p2p-host.ts",
|
||||||
"test:p2p-peers": "deno test -A --no-check test-p2p-peers-local-relay.ts",
|
"test:p2p-peers": "deno test --env-file=.test.env -A --no-check test-p2p-peers-local-relay.ts",
|
||||||
"test:p2p-sync": "deno test -A --no-check test-p2p-sync.ts",
|
"test:p2p-sync": "deno test --env-file=.test.env -A --no-check test-p2p-sync.ts",
|
||||||
"test:p2p-three-nodes": "deno test -A --no-check test-p2p-three-nodes-conflict.ts",
|
"test:p2p-three-nodes": "deno test --env-file=.test.env -A --no-check test-p2p-three-nodes-conflict.ts",
|
||||||
"test:p2p-upload-download": "deno test -A --no-check test-p2p-upload-download-repro.ts",
|
"test:p2p-upload-download": "deno test --env-file=.test.env -A --no-check test-p2p-upload-download-repro.ts",
|
||||||
"test:e2e-couchdb": "deno test -A --no-check test-e2e-two-vaults-couchdb.ts",
|
"test:e2e-couchdb": "deno test --env-file=.test.env -A --no-check test-e2e-two-vaults-couchdb.ts",
|
||||||
"test:e2e-matrix": "deno test -A --no-check test-e2e-two-vaults-matrix.ts"
|
"test:e2e-matrix": "deno test --env-file=.test.env -A --no-check test-e2e-two-vaults-matrix.ts"
|
||||||
},
|
},
|
||||||
"imports": {
|
"imports": {
|
||||||
"@std/assert": "jsr:@std/assert@^1.0.13",
|
"@std/assert": "jsr:@std/assert@^1.0.13",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { assert } from "@std/assert";
|
import { assert } from "@std/assert";
|
||||||
import { TempDir } from "./helpers/temp.ts";
|
import { TempDir } from "./helpers/temp.ts";
|
||||||
import { loadEnvFile } from "./helpers/env.ts";
|
|
||||||
import {
|
import {
|
||||||
runCli,
|
runCli,
|
||||||
runCliOrFail,
|
runCliOrFail,
|
||||||
@@ -11,31 +10,29 @@ import {
|
|||||||
} from "./helpers/cli.ts";
|
} from "./helpers/cli.ts";
|
||||||
import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts";
|
import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||||
import { startCouchdb, startMinio, stopCouchdb, stopMinio } from "./helpers/docker.ts";
|
import { startCouchdb, startMinio, stopCouchdb, stopMinio } from "./helpers/docker.ts";
|
||||||
import { join } from "@std/path";
|
|
||||||
|
|
||||||
const TEST_ENV = join(import.meta.dirname!, "..", ".test.env");
|
|
||||||
type RemoteType = "COUCHDB" | "MINIO";
|
type RemoteType = "COUCHDB" | "MINIO";
|
||||||
|
|
||||||
function requireEnv(env: Record<string, string>, key: string): string {
|
function requireEnv(...keys: string[]): string {
|
||||||
const value = env[key]?.trim();
|
for (const key of keys) {
|
||||||
if (!value) throw new Error(`Required env var is missing: ${key}`);
|
const value = Deno.env.get(key)?.trim();
|
||||||
return value;
|
if (value) return value;
|
||||||
|
}
|
||||||
|
throw new Error(`Required env var is missing: ${keys.join(" or ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runScenario(remoteType: RemoteType, encrypt: boolean): Promise<void> {
|
export async function runScenario(remoteType: RemoteType, encrypt: boolean): Promise<void> {
|
||||||
const env = await loadEnvFile(TEST_ENV);
|
|
||||||
const dbSuffix = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
const dbSuffix = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||||
|
|
||||||
const couchdbUri = remoteType === "COUCHDB" ? requireEnv(env, "hostname").replace(/\/$/, "") : "";
|
const couchdbUri = remoteType === "COUCHDB" ? requireEnv("COUCHDB_URI", "hostname").replace(/\/$/, "") : "";
|
||||||
const couchdbUser = remoteType === "COUCHDB" ? requireEnv(env, "username") : "";
|
const couchdbUser = remoteType === "COUCHDB" ? requireEnv("COUCHDB_USER", "username") : "";
|
||||||
const couchdbPassword = remoteType === "COUCHDB" ? requireEnv(env, "password") : "";
|
const couchdbPassword = remoteType === "COUCHDB" ? requireEnv("COUCHDB_PASSWORD", "password") : "";
|
||||||
const dbPrefix = remoteType === "COUCHDB" ? requireEnv(env, "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(env, "minioEndpoint").replace(/\/$/, "") : "";
|
const minioEndpoint = remoteType === "MINIO" ? requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/, "") : "";
|
||||||
const minioAccessKey = remoteType === "MINIO" ? requireEnv(env, "accessKey") : "";
|
const minioAccessKey = remoteType === "MINIO" ? requireEnv("MINIO_ACCESS_KEY", "accessKey") : "";
|
||||||
const minioSecretKey = remoteType === "MINIO" ? requireEnv(env, "secretKey") : "";
|
const minioSecretKey = remoteType === "MINIO" ? requireEnv("MINIO_SECRET_KEY", "secretKey") : "";
|
||||||
const minioBucketBase = remoteType === "MINIO" ? requireEnv(env, "bucketName") : "";
|
const minioBucketBase = remoteType === "MINIO" ? requireEnv("MINIO_BUCKET_NAME", "bucketName") : "";
|
||||||
const minioBucket = remoteType === "MINIO" ? `${minioBucketBase}-${dbSuffix}` : "";
|
const minioBucket = remoteType === "MINIO" ? `${minioBucketBase}-${dbSuffix}` : "";
|
||||||
|
|
||||||
const passphrase = "e2e-passphrase";
|
const passphrase = "e2e-passphrase";
|
||||||
|
|||||||
@@ -6,30 +6,26 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { assert, assertStringIncludes } from "@std/assert";
|
import { assert, assertStringIncludes } from "@std/assert";
|
||||||
import { join } from "@std/path";
|
|
||||||
import { loadEnvFile } from "./helpers/env.ts";
|
|
||||||
import { TempDir } from "./helpers/temp.ts";
|
import { TempDir } from "./helpers/temp.ts";
|
||||||
import { runCli } from "./helpers/cli.ts";
|
import { runCli } from "./helpers/cli.ts";
|
||||||
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||||
import { createCouchdbDatabase, startCouchdb, stopCouchdb, updateCouchdbDoc } from "./helpers/docker.ts";
|
import { createCouchdbDatabase, startCouchdb, stopCouchdb, updateCouchdbDoc } from "./helpers/docker.ts";
|
||||||
|
|
||||||
const TEST_ENV = join(import.meta.dirname!, "..", ".test.env");
|
|
||||||
const MILESTONE_DOC = "_local/obsydian_livesync_milestone";
|
const MILESTONE_DOC = "_local/obsydian_livesync_milestone";
|
||||||
|
|
||||||
function requireEnv(env: Record<string, string>, key: string): string {
|
function requireEnv(...keys: string[]): string {
|
||||||
const value = env[key]?.trim();
|
for (const key of keys) {
|
||||||
if (!value) {
|
const value = Deno.env.get(key)?.trim();
|
||||||
throw new Error(`Required env var is missing: ${key}`);
|
if (value) return value;
|
||||||
}
|
}
|
||||||
return value;
|
throw new Error(`Required env var is missing: ${keys.join(" or ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
Deno.test("sync: actionable error against locked remote DB", async () => {
|
Deno.test("sync: actionable error against locked remote DB", async () => {
|
||||||
const env = await loadEnvFile(TEST_ENV);
|
const couchdbUri = requireEnv("COUCHDB_URI", "hostname").replace(/\/$/, "");
|
||||||
const couchdbUri = requireEnv(env, "hostname").replace(/\/$/, "");
|
const couchdbUser = requireEnv("COUCHDB_USER", "username");
|
||||||
const couchdbUser = requireEnv(env, "username");
|
const couchdbPassword = requireEnv("COUCHDB_PASSWORD", "password");
|
||||||
const couchdbPassword = requireEnv(env, "password");
|
const dbPrefix = requireEnv("COUCHDB_DBNAME", "dbname");
|
||||||
const dbPrefix = requireEnv(env, "dbname");
|
|
||||||
const dbname = `${dbPrefix}-locked-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
const dbname = `${dbPrefix}-locked-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||||
|
|
||||||
await using workDir = await TempDir.create("livesync-cli-locked-test");
|
await using workDir = await TempDir.create("livesync-cli-locked-test");
|
||||||
|
|||||||
@@ -23,13 +23,11 @@
|
|||||||
* deno test -A test-sync-two-local-databases.ts
|
* deno test -A test-sync-two-local-databases.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { join } from "@std/path";
|
|
||||||
import { assertEquals, assert } from "@std/assert";
|
import { assertEquals, assert } from "@std/assert";
|
||||||
import { TempDir } from "./helpers/temp.ts";
|
import { TempDir } from "./helpers/temp.ts";
|
||||||
import { CLI_DIR, runCliOrFail, jsonFieldIsNa } from "./helpers/cli.ts";
|
import { runCliOrFail, jsonFieldIsNa } from "./helpers/cli.ts";
|
||||||
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||||
import { startCouchdb, stopCouchdb } from "./helpers/docker.ts";
|
import { startCouchdb, stopCouchdb } from "./helpers/docker.ts";
|
||||||
import { loadEnvFile } from "./helpers/env.ts";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Load configuration
|
// Load configuration
|
||||||
@@ -41,20 +39,7 @@ async function resolveConfig(): Promise<{
|
|||||||
password: string;
|
password: string;
|
||||||
baseDbname: string;
|
baseDbname: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
let env: Record<string, string> = {};
|
const env = Deno.env.toObject();
|
||||||
|
|
||||||
// 1. Explicit environment variables take priority
|
|
||||||
if (Deno.env.get("COUCHDB_URI")) {
|
|
||||||
env = Object.fromEntries(Deno.env.toObject());
|
|
||||||
} else {
|
|
||||||
// 2. TEST_ENV_FILE env var
|
|
||||||
const envFile = Deno.env.get("TEST_ENV_FILE") ?? join(CLI_DIR, ".test.env");
|
|
||||||
try {
|
|
||||||
env = await loadEnvFile(envFile);
|
|
||||||
} catch {
|
|
||||||
return null; // no config available — skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const uri = (env["COUCHDB_URI"] ?? env["hostname"] ?? "").replace(/\/$/, "");
|
const uri = (env["COUCHDB_URI"] ?? env["hostname"] ?? "").replace(/\/$/, "");
|
||||||
const user = env["COUCHDB_USER"] ?? env["username"] ?? "";
|
const user = env["COUCHDB_USER"] ?? env["username"] ?? "";
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ export class DocumentHistoryModal extends Modal {
|
|||||||
currentDeleted = false;
|
currentDeleted = false;
|
||||||
initialRev?: string;
|
initialRev?: string;
|
||||||
|
|
||||||
|
// Diff navigation state
|
||||||
|
currentDiffIndex = -1;
|
||||||
|
diffNavContainer!: HTMLDivElement;
|
||||||
|
diffNavIndicator!: HTMLSpanElement;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
app: App,
|
app: App,
|
||||||
core: LiveSyncBaseCore,
|
core: LiveSyncBaseCore,
|
||||||
@@ -216,6 +221,64 @@ export class DocumentHistoryModal extends Modal {
|
|||||||
this.contentView.innerHTML =
|
this.contentView.innerHTML =
|
||||||
(this.currentDeleted ? "(At this revision, the file has been deleted)\n" : "") + result;
|
(this.currentDeleted ? "(At this revision, the file has been deleted)\n" : "") + result;
|
||||||
}
|
}
|
||||||
|
// Reset diff navigation after content changes
|
||||||
|
this.resetDiffNavigation();
|
||||||
|
if (this.showDiff) {
|
||||||
|
this.navigateDiff("next");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the previous or next diff block in the content view.
|
||||||
|
* Only effective when diff highlighting is enabled.
|
||||||
|
*/
|
||||||
|
navigateDiff(direction: "prev" | "next") {
|
||||||
|
const diffElements = this.contentView.querySelectorAll(".history-added, .history-deleted");
|
||||||
|
if (diffElements.length === 0) return;
|
||||||
|
|
||||||
|
// Remove previous focus highlight
|
||||||
|
const prevFocused = this.contentView.querySelector(".diff-focused");
|
||||||
|
if (prevFocused) {
|
||||||
|
prevFocused.classList.remove("diff-focused");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction === "next") {
|
||||||
|
this.currentDiffIndex = (this.currentDiffIndex + 1) % diffElements.length;
|
||||||
|
} else {
|
||||||
|
this.currentDiffIndex =
|
||||||
|
this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = diffElements[this.currentDiffIndex];
|
||||||
|
target.classList.add("diff-focused");
|
||||||
|
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
|
||||||
|
this.diffNavIndicator.textContent = `${this.currentDiffIndex + 1}/${diffElements.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the diff navigation index and update the indicator.
|
||||||
|
*/
|
||||||
|
resetDiffNavigation() {
|
||||||
|
this.currentDiffIndex = -1;
|
||||||
|
if (this.diffNavIndicator) {
|
||||||
|
if (this.showDiff) {
|
||||||
|
const diffElements = this.contentView.querySelectorAll(".history-added, .history-deleted");
|
||||||
|
this.diffNavIndicator.textContent = diffElements.length > 0 ? `0/${diffElements.length}` : "\u2014";
|
||||||
|
} else {
|
||||||
|
this.diffNavIndicator.textContent = "\u2014";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.updateDiffNavVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show or hide the diff navigation buttons based on the showDiff state.
|
||||||
|
*/
|
||||||
|
updateDiffNavVisibility() {
|
||||||
|
if (this.diffNavContainer) {
|
||||||
|
this.diffNavContainer.style.display = this.showDiff ? "flex" : "none";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override onOpen() {
|
override onOpen() {
|
||||||
@@ -236,25 +299,47 @@ export class DocumentHistoryModal extends Modal {
|
|||||||
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
contentEl
|
const diffOptionsRow = contentEl.createDiv("");
|
||||||
.createDiv("", (e) => {
|
diffOptionsRow.addClass("op-info");
|
||||||
e.createEl("label", {}, (label) => {
|
diffOptionsRow.addClass("diff-options-row");
|
||||||
label.appendChild(
|
|
||||||
createEl("input", { type: "checkbox" }, (checkbox) => {
|
diffOptionsRow.createEl("label", {}, (label) => {
|
||||||
if (this.showDiff) {
|
label.appendChild(
|
||||||
checkbox.checked = true;
|
createEl("input", { type: "checkbox" }, (checkbox) => {
|
||||||
}
|
if (this.showDiff) {
|
||||||
checkbox.addEventListener("input", (evt: any) => {
|
checkbox.checked = true;
|
||||||
this.showDiff = checkbox.checked;
|
}
|
||||||
localStorage.setItem("ols-history-highlightdiff", this.showDiff == true ? "1" : "");
|
checkbox.addEventListener("input", (evt: any) => {
|
||||||
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
this.showDiff = checkbox.checked;
|
||||||
});
|
localStorage.setItem("ols-history-highlightdiff", this.showDiff == true ? "1" : "");
|
||||||
})
|
this.updateDiffNavVisibility();
|
||||||
);
|
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
||||||
label.appendText("Highlight diff");
|
});
|
||||||
});
|
})
|
||||||
})
|
);
|
||||||
.addClass("op-info");
|
label.appendText("Highlight diff");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Diff navigation buttons
|
||||||
|
this.diffNavContainer = diffOptionsRow.createDiv("");
|
||||||
|
this.diffNavContainer.addClass("diff-nav");
|
||||||
|
this.diffNavContainer.style.display = this.showDiff ? "flex" : "none";
|
||||||
|
|
||||||
|
this.diffNavContainer.createEl("button", { text: "\u25B2 Prev" }, (e) => {
|
||||||
|
e.addClass("diff-nav-btn");
|
||||||
|
e.addEventListener("click", () => {
|
||||||
|
this.navigateDiff("prev");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.diffNavContainer.createEl("button", { text: "\u25BC Next" }, (e) => {
|
||||||
|
e.addClass("diff-nav-btn");
|
||||||
|
e.addEventListener("click", () => {
|
||||||
|
this.navigateDiff("next");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.diffNavIndicator = this.diffNavContainer.createEl("span", { text: "\u2014" });
|
||||||
|
this.diffNavIndicator.addClass("diff-nav-indicator");
|
||||||
|
|
||||||
this.info = contentEl.createDiv("");
|
this.info = contentEl.createDiv("");
|
||||||
this.info.addClass("op-info");
|
this.info.addClass("op-info");
|
||||||
fireAndForget(async () => await this.loadFile(this.initialRev));
|
fireAndForget(async () => await this.loadFile(this.initialRev));
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
import { type ObsidianLiveSyncSettings, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "../../lib/src/common/types.ts";
|
|
||||||
import { configURIBase } from "../../common/types.ts";
|
|
||||||
// import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser.js";
|
|
||||||
import { fireAndForget } from "../../lib/src/common/utils.ts";
|
|
||||||
import {
|
|
||||||
EVENT_REQUEST_COPY_SETUP_URI,
|
|
||||||
EVENT_REQUEST_OPEN_P2P_SETTINGS,
|
|
||||||
EVENT_REQUEST_OPEN_SETUP_URI,
|
|
||||||
EVENT_REQUEST_SHOW_SETUP_QR,
|
|
||||||
eventHub,
|
|
||||||
} from "../../common/events.ts";
|
|
||||||
import { $msg } from "../../lib/src/common/i18n.ts";
|
|
||||||
// import { performDoctorConsultation, RebuildOptions } from "@/lib/src/common/configForDoc.ts";
|
|
||||||
import type { LiveSyncCore } from "../../main.ts";
|
|
||||||
import {
|
|
||||||
encodeQR,
|
|
||||||
encodeSettingsToQRCodeData,
|
|
||||||
encodeSettingsToSetupURI,
|
|
||||||
OutputFormat,
|
|
||||||
} from "../../lib/src/API/processSetting.ts";
|
|
||||||
import { SetupManager, UserMode } from "./SetupManager.ts";
|
|
||||||
import { AbstractModule } from "../AbstractModule.ts";
|
|
||||||
|
|
||||||
export class ModuleSetupObsidian extends AbstractModule {
|
|
||||||
private _setupManager!: SetupManager;
|
|
||||||
private _everyOnload(): Promise<boolean> {
|
|
||||||
this._setupManager = this.core.getModule(SetupManager);
|
|
||||||
try {
|
|
||||||
this.registerObsidianProtocolHandler("setuplivesync", async (conf: any) => {
|
|
||||||
if (conf.settings) {
|
|
||||||
await this._setupManager.onUseSetupURI(
|
|
||||||
UserMode.Unknown,
|
|
||||||
`${configURIBase}${encodeURIComponent(conf.settings)}`
|
|
||||||
);
|
|
||||||
} else if (conf.settingsQR) {
|
|
||||||
await this._setupManager.decodeQR(conf.settingsQR);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
this._log(
|
|
||||||
"Failed to register protocol handler. This feature may not work in some environments.",
|
|
||||||
LOG_LEVEL_NOTICE
|
|
||||||
);
|
|
||||||
this._log(e, LOG_LEVEL_VERBOSE);
|
|
||||||
}
|
|
||||||
this.addCommand({
|
|
||||||
id: "livesync-setting-qr",
|
|
||||||
name: "Show settings as a QR code",
|
|
||||||
callback: () => fireAndForget(this.encodeQR()),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.addCommand({
|
|
||||||
id: "livesync-copysetupuri",
|
|
||||||
name: "Copy settings as a new setup URI",
|
|
||||||
callback: () => fireAndForget(this.command_copySetupURI()),
|
|
||||||
});
|
|
||||||
this.addCommand({
|
|
||||||
id: "livesync-copysetupuri-short",
|
|
||||||
name: "Copy settings as a new setup URI (With customization sync)",
|
|
||||||
callback: () => fireAndForget(this.command_copySetupURIWithSync()),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.addCommand({
|
|
||||||
id: "livesync-copysetupurifull",
|
|
||||||
name: "Copy settings as a new setup URI (Full)",
|
|
||||||
callback: () => fireAndForget(this.command_copySetupURIFull()),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.addCommand({
|
|
||||||
id: "livesync-opensetupuri",
|
|
||||||
name: "Use the copied setup URI (Formerly Open setup URI)",
|
|
||||||
callback: () => fireAndForget(this.command_openSetupURI()),
|
|
||||||
});
|
|
||||||
|
|
||||||
eventHub.onEvent(EVENT_REQUEST_OPEN_SETUP_URI, () => fireAndForget(() => this.command_openSetupURI()));
|
|
||||||
eventHub.onEvent(EVENT_REQUEST_COPY_SETUP_URI, () => fireAndForget(() => this.command_copySetupURI()));
|
|
||||||
eventHub.onEvent(EVENT_REQUEST_SHOW_SETUP_QR, () => fireAndForget(() => this.encodeQR()));
|
|
||||||
eventHub.onEvent(EVENT_REQUEST_OPEN_P2P_SETTINGS, () =>
|
|
||||||
fireAndForget(() => {
|
|
||||||
return this._setupManager.onP2PManualSetup(UserMode.Update, this.settings, false);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return Promise.resolve(true);
|
|
||||||
}
|
|
||||||
async encodeQR() {
|
|
||||||
const settingString = encodeSettingsToQRCodeData(this.settings);
|
|
||||||
const codeSVG = encodeQR(settingString, OutputFormat.SVG);
|
|
||||||
if (codeSVG == "") {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const msg = $msg("Setup.QRCode", { qr_image: codeSVG });
|
|
||||||
await this.core.confirm.confirmWithMessage("Settings QR Code", msg, ["OK"], "OK");
|
|
||||||
return await Promise.resolve(codeSVG);
|
|
||||||
}
|
|
||||||
|
|
||||||
async askEncryptingPassphrase(): Promise<string | false> {
|
|
||||||
const encryptingPassphrase = await this.core.confirm.askString(
|
|
||||||
"Encrypt your settings",
|
|
||||||
"The passphrase to encrypt the setup URI",
|
|
||||||
"",
|
|
||||||
true
|
|
||||||
);
|
|
||||||
return encryptingPassphrase;
|
|
||||||
}
|
|
||||||
|
|
||||||
async command_copySetupURI(stripExtra = true) {
|
|
||||||
const encryptingPassphrase = await this.askEncryptingPassphrase();
|
|
||||||
if (encryptingPassphrase === false) return;
|
|
||||||
const encryptedURI = await encodeSettingsToSetupURI(
|
|
||||||
this.settings,
|
|
||||||
encryptingPassphrase,
|
|
||||||
[...((stripExtra ? ["pluginSyncExtendedSetting"] : []) as (keyof ObsidianLiveSyncSettings)[])],
|
|
||||||
true
|
|
||||||
);
|
|
||||||
if (await this.services.UI.promptCopyToClipboard("Setup URI", encryptedURI)) {
|
|
||||||
this._log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
|
|
||||||
}
|
|
||||||
// await navigator.clipboard.writeText(encryptedURI);
|
|
||||||
}
|
|
||||||
|
|
||||||
async command_copySetupURIFull() {
|
|
||||||
const encryptingPassphrase = await this.askEncryptingPassphrase();
|
|
||||||
if (encryptingPassphrase === false) return;
|
|
||||||
const encryptedURI = await encodeSettingsToSetupURI(this.settings, encryptingPassphrase, [], false);
|
|
||||||
await navigator.clipboard.writeText(encryptedURI);
|
|
||||||
this._log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
|
|
||||||
}
|
|
||||||
|
|
||||||
async command_copySetupURIWithSync() {
|
|
||||||
await this.command_copySetupURI(false);
|
|
||||||
}
|
|
||||||
async command_openSetupURI() {
|
|
||||||
await this._setupManager.onUseSetupURI(UserMode.Unknown);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Where to implement these?
|
|
||||||
|
|
||||||
// async askSyncWithRemoteConfig(tryingSettings: ObsidianLiveSyncSettings): Promise<ObsidianLiveSyncSettings> {
|
|
||||||
// const buttons = {
|
|
||||||
// fetch: $msg("Setup.FetchRemoteConf.Buttons.Fetch"),
|
|
||||||
// no: $msg("Setup.FetchRemoteConf.Buttons.Skip"),
|
|
||||||
// } as const;
|
|
||||||
// const fetchRemoteConf = await this.core.confirm.askSelectStringDialogue(
|
|
||||||
// $msg("Setup.FetchRemoteConf.Message"),
|
|
||||||
// Object.values(buttons),
|
|
||||||
// { defaultAction: buttons.fetch, timeout: 0, title: $msg("Setup.FetchRemoteConf.Title") }
|
|
||||||
// );
|
|
||||||
// if (fetchRemoteConf == buttons.no) {
|
|
||||||
// return tryingSettings;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const newSettings = JSON.parse(JSON.stringify(tryingSettings)) as ObsidianLiveSyncSettings;
|
|
||||||
// const remoteConfig = await this.services.tweakValue.fetchRemotePreferred(newSettings);
|
|
||||||
// if (remoteConfig) {
|
|
||||||
// this._log("Remote configuration found.", LOG_LEVEL_NOTICE);
|
|
||||||
// const resultSettings = {
|
|
||||||
// ...DEFAULT_SETTINGS,
|
|
||||||
// ...tryingSettings,
|
|
||||||
// ...remoteConfig,
|
|
||||||
// } satisfies ObsidianLiveSyncSettings;
|
|
||||||
// return resultSettings;
|
|
||||||
// } else {
|
|
||||||
// this._log("Remote configuration not applied.", LOG_LEVEL_NOTICE);
|
|
||||||
// return {
|
|
||||||
// ...DEFAULT_SETTINGS,
|
|
||||||
// ...tryingSettings,
|
|
||||||
// } satisfies ObsidianLiveSyncSettings;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// async askPerformDoctor(
|
|
||||||
// tryingSettings: ObsidianLiveSyncSettings
|
|
||||||
// ): Promise<{ settings: ObsidianLiveSyncSettings; shouldRebuild: boolean; isModified: boolean }> {
|
|
||||||
// const buttons = {
|
|
||||||
// yes: $msg("Setup.Doctor.Buttons.Yes"),
|
|
||||||
// no: $msg("Setup.Doctor.Buttons.No"),
|
|
||||||
// } as const;
|
|
||||||
// const performDoctor = await this.core.confirm.askSelectStringDialogue(
|
|
||||||
// $msg("Setup.Doctor.Message"),
|
|
||||||
// Object.values(buttons),
|
|
||||||
// { defaultAction: buttons.yes, timeout: 0, title: $msg("Setup.Doctor.Title") }
|
|
||||||
// );
|
|
||||||
// if (performDoctor == buttons.no) {
|
|
||||||
// return { settings: tryingSettings, shouldRebuild: false, isModified: false };
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const newSettings = JSON.parse(JSON.stringify(tryingSettings)) as ObsidianLiveSyncSettings;
|
|
||||||
// const { settings, shouldRebuild, isModified } = await performDoctorConsultation(this.core, newSettings, {
|
|
||||||
// localRebuild: RebuildOptions.AutomaticAcceptable, // Because we are in the setup wizard, we can skip the confirmation.
|
|
||||||
// remoteRebuild: RebuildOptions.SkipEvenIfRequired,
|
|
||||||
// activateReason: "New settings from URI",
|
|
||||||
// });
|
|
||||||
// if (isModified) {
|
|
||||||
// this._log("Doctor has fixed some issues!", LOG_LEVEL_NOTICE);
|
|
||||||
// return {
|
|
||||||
// settings,
|
|
||||||
// shouldRebuild,
|
|
||||||
// isModified,
|
|
||||||
// };
|
|
||||||
// } else {
|
|
||||||
// this._log("Doctor detected no issues!", LOG_LEVEL_NOTICE);
|
|
||||||
// return { settings: tryingSettings, shouldRebuild: false, isModified: false };
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
|
||||||
services.appLifecycle.onLoaded.addHandler(this._everyOnload.bind(this));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
41
styles.css
41
styles.css
@@ -484,4 +484,45 @@ div.workspace-leaf-content[data-type=bases] .livesync-status {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Diff navigation */
|
||||||
|
.diff-options-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-nav-btn {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-nav-btn:hover {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-nav-indicator {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 3em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-focused {
|
||||||
|
outline: 2px solid var(--interactive-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user