mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-27 01:31:35 +00:00
Merge remote-tracking branch 'origin/main' into feat/history-search
# Conflicts: # src/lib # src/modules/features/DocumentHistory/DocumentHistoryModal.ts
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
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 { HasSettings, ObsidianLiveSyncSettings, EntryDoc } from "./lib/src/common/types";
|
||||
import { __$checkInstanceBinding } from "./lib/src/dev/checks";
|
||||
@@ -123,7 +124,7 @@ export class LiveSyncBaseCore<
|
||||
for (const module of this.modules) {
|
||||
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);
|
||||
__$checkInstanceBinding(module); // Check if all functions are properly bound, and log warnings if not.
|
||||
} else {
|
||||
// module should not be never.
|
||||
const moduleName = (module as unknown)?.constructor?.name ?? "unknown";
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ RUN apt-get update \
|
||||
WORKDIR /build
|
||||
|
||||
# Install workspace dependencies first (layer-cache friendly)
|
||||
COPY package.json ./
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy the full source tree and build the CLI bundle
|
||||
|
||||
@@ -74,6 +74,12 @@ livesync-cli [database-path] [command] [args...]
|
||||
- `pull <src> <dst>`: Pull a file `<src>` from the database into local file `<dst>`.
|
||||
- `cat <src>`: Read a file from the database and write to stdout.
|
||||
- `put <dst>`: Read from stdin and write to the database path `<dst>`.
|
||||
- `remote-add <name> <connstr>`: Add a remote configuration from a connection string.
|
||||
- `remote-rm <remote-id>`: Remove a remote configuration by ID.
|
||||
- `remote-ls`: List remote configurations (`id`, `name`, `active/inactive`, redacted URI).
|
||||
- `remote-export <remote-id>`: Export the stored connection string by remote ID.
|
||||
- `remote-set <remote-id> <connstr>`: Replace the stored connection string by remote ID.
|
||||
- `remote-activate <remote-id>`: Activate a remote configuration by ID.
|
||||
- `init-settings [file]`: Create a default settings file.
|
||||
|
||||
### Examples
|
||||
@@ -92,39 +98,39 @@ livesync-cli ./my-db pull folder/note.md ./note.md
|
||||
|
||||
## Installation
|
||||
|
||||
### 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.
|
||||
### Build from source
|
||||
|
||||
Run the CLI:
|
||||
|
||||
```bash
|
||||
# Run with npm script (from repository root)
|
||||
npm run --silent cli -- [database-path] [command] [args...]
|
||||
```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.
|
||||
|
||||
Run the CLI:
|
||||
|
||||
```bash
|
||||
# Run with npm script (from repository root)
|
||||
npm run --silent cli -- [database-path] [command] [args...]
|
||||
# Run the built executable directly
|
||||
node src/apps/cli/dist/index.cjs [database-path] [command] [args...]
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
A Docker image is provided for headless / server deployments. Build from the repository root:
|
||||
### Docker
|
||||
|
||||
A Docker image is provided for headless / server deployments. Build from the repository root:
|
||||
|
||||
```bash
|
||||
docker build -f src/apps/cli/Dockerfile -t livesync-cli .
|
||||
@@ -252,6 +258,14 @@ livesync-cli /path/to/your-local-database --settings /path/to/settings.json rm /
|
||||
|
||||
# Resolve conflict by keeping a specific revision
|
||||
livesync-cli /path/to/your-local-database --settings /path/to/settings.json resolve /vault/path/file.md 3-abcdef
|
||||
|
||||
# Add/list/activate/remove remote configurations
|
||||
livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-add main "sls+https://user:pass@example.com/db"
|
||||
livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-ls
|
||||
livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-export remote-abc123
|
||||
livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-set remote-abc123 "sls+p2p://room-abc?passphrase=secret"
|
||||
livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-activate remote-abc123
|
||||
livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-rm remote-abc123
|
||||
```
|
||||
|
||||
### Configuration
|
||||
@@ -297,9 +311,11 @@ Options:
|
||||
--force, -f Overwrite existing file on init-settings
|
||||
--verbose, -v Enable verbose logging
|
||||
--debug, -d Enable debug logging (includes verbose)
|
||||
--help, -h Show help message
|
||||
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
|
||||
--help, -h Show this help message
|
||||
|
||||
Commands:
|
||||
daemon (default) Run mirror scan then continuously sync CouchDB <-> local filesystem
|
||||
init-settings [path] Create settings JSON from DEFAULT_SETTINGS
|
||||
sync Run one replication cycle and exit
|
||||
p2p-peers <timeout> Show discovered peers as [peer]<TAB><peer-id><TAB><peer-name>
|
||||
@@ -406,6 +422,86 @@ In other words, it performs the following actions:
|
||||
|
||||
Note: `mirror` does not respect file deletions. If a file is deleted in storage, it will be restored on the next `mirror` run. To delete a file, use the `rm` command instead. This is a little inconvenient, but it is intentional behaviour (if we handle this automatically in `mirror`, we should be against a ton of edge cases).
|
||||
|
||||
##### daemon
|
||||
|
||||
`daemon` is the default command when no command is specified. It runs an initial mirror scan and then continuously syncs changes in both directions:
|
||||
|
||||
- **CouchDB → local filesystem**: via the `_changes` feed (LiveSync mode, default) or periodic polling (`--interval N`).
|
||||
- **local filesystem → CouchDB**: via chokidar file watching. Any file created, modified, or deleted in the vault directory is pushed to CouchDB.
|
||||
|
||||
In **LiveSync mode** the `_changes` feed delivers remote changes as they arrive, with sub-second latency. In **polling mode** (`--interval N`) the CLI polls CouchDB every N seconds. Use polling mode if your CouchDB instance does not support long-lived HTTP connections, or if you need predictable network usage.
|
||||
|
||||
The daemon exits cleanly on `SIGINT` or `SIGTERM`.
|
||||
|
||||
```bash
|
||||
# LiveSync mode (default — _changes feed, near-real-time)
|
||||
livesync-cli /path/to/vault
|
||||
|
||||
# Polling mode — poll every 60 seconds
|
||||
livesync-cli /path/to/vault --interval 60
|
||||
```
|
||||
|
||||
### .livesync/ignore
|
||||
|
||||
Place a `.livesync/ignore` file in your vault root to exclude files from sync in both directions (local → CouchDB and CouchDB → local).
|
||||
|
||||
**Format:**
|
||||
|
||||
- Lines beginning with `#` are comments.
|
||||
- Blank lines are ignored.
|
||||
- All other lines are [minimatch](https://github.com/isaacs/minimatch) glob patterns, relative to the vault root.
|
||||
- The directive `import: .gitignore` (exactly this string) reads `.gitignore` from the vault root and merges its non-comment, non-blank lines into the ignore rules.
|
||||
- Negation patterns (lines starting with `!`) are not supported and will cause an error on load.
|
||||
|
||||
**Example `.livesync/ignore`:**
|
||||
|
||||
```
|
||||
# Ignore temporary files
|
||||
*.tmp
|
||||
*.swp
|
||||
|
||||
# Ignore build output
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Merge patterns from .gitignore
|
||||
import: .gitignore
|
||||
```
|
||||
|
||||
Patterns apply in both directions: the chokidar watcher will not emit events for matched files, and the `isTargetFile` filter will exclude them from CouchDB → local sync.
|
||||
|
||||
Changes to this file require a daemon restart to take effect.
|
||||
|
||||
### Systemd Installation
|
||||
|
||||
The `deploy/` directory contains a systemd unit template and an install script.
|
||||
|
||||
**Automated install (user service, recommended):**
|
||||
|
||||
```bash
|
||||
bash src/apps/cli/deploy/install.sh --vault /path/to/vault
|
||||
```
|
||||
|
||||
**With polling interval:**
|
||||
|
||||
```bash
|
||||
bash src/apps/cli/deploy/install.sh --vault /path/to/vault --interval 60
|
||||
```
|
||||
|
||||
**System-wide install** (requires root / sudo for `/etc/systemd/system/`):
|
||||
|
||||
```bash
|
||||
bash src/apps/cli/deploy/install.sh --system --vault /path/to/vault
|
||||
```
|
||||
|
||||
The script:
|
||||
1. Builds the CLI (`npm install` + `npm run build`).
|
||||
2. Installs the binary to `~/.local/bin/livesync-cli` (user) or `/usr/local/bin/livesync-cli` (system).
|
||||
3. Writes the unit file to `~/.config/systemd/user/livesync-cli.service` (user) or `/etc/systemd/system/livesync-cli.service` (system).
|
||||
4. Runs `systemctl [--user] daemon-reload && systemctl [--user] enable --now livesync-cli`.
|
||||
|
||||
**Manual setup** — if you prefer to manage the unit yourself, copy `deploy/livesync-cli.service`, replace `LIVESYNC_BIN` and `LIVESYNC_VAULT_PATH` with the actual binary path and vault path, then install to the appropriate systemd directory.
|
||||
|
||||
### Planned options:
|
||||
|
||||
- `--immediate`: Perform sync after the command (e.g. `push`, `pull`, `put`, `rm`).
|
||||
|
||||
@@ -39,12 +39,6 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
|
||||
async getAbstractFileByPath(p: FilePath | string): Promise<NodeFile | null> {
|
||||
const pathStr = this.normalisePath(p);
|
||||
|
||||
const cached = this.fileCache.get(pathStr);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
return await this.refreshFile(pathStr);
|
||||
}
|
||||
|
||||
@@ -104,14 +98,15 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
path: pathStr as FilePath,
|
||||
stat: {
|
||||
size: stat.size,
|
||||
mtime: stat.mtimeMs,
|
||||
ctime: stat.ctimeMs,
|
||||
mtime: Math.floor(stat.mtimeMs),
|
||||
ctime: Math.floor(stat.ctimeMs),
|
||||
type: "file",
|
||||
},
|
||||
};
|
||||
this.fileCache.set(pathStr, file);
|
||||
return file;
|
||||
} catch {
|
||||
// Evict so a deleted file is not returned by subsequent cache scans.
|
||||
this.fileCache.delete(pathStr);
|
||||
return null;
|
||||
}
|
||||
@@ -137,8 +132,8 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
path: entryRelativePath as FilePath,
|
||||
stat: {
|
||||
size: stat.size,
|
||||
mtime: stat.mtimeMs,
|
||||
ctime: stat.ctimeMs,
|
||||
mtime: Math.floor(stat.mtimeMs),
|
||||
ctime: Math.floor(stat.ctimeMs),
|
||||
type: "file",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -28,8 +28,8 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
||||
const stat = await fs.stat(this.resolvePath(p));
|
||||
return {
|
||||
size: stat.size,
|
||||
mtime: stat.mtimeMs,
|
||||
ctime: stat.ctimeMs,
|
||||
mtime: Math.floor(stat.mtimeMs),
|
||||
ctime: Math.floor(stat.ctimeMs),
|
||||
type: stat.isDirectory() ? "folder" : "file",
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -15,7 +15,12 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
||||
}
|
||||
|
||||
async read(file: NodeFile): Promise<string> {
|
||||
return await fs.readFile(this.resolvePath(file.path), "utf-8");
|
||||
const content = await fs.readFile(this.resolvePath(file.path), "utf-8");
|
||||
// Correct stale stat.size — chokidar stats may be from a poll before the final write.
|
||||
// The downstream document integrity check compares stat.size to content length, so
|
||||
// they must agree or other clients reject the file as corrupted.
|
||||
file.stat.size = Buffer.byteLength(content, "utf-8");
|
||||
return content;
|
||||
}
|
||||
|
||||
async cachedRead(file: NodeFile): Promise<string> {
|
||||
@@ -25,6 +30,8 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
||||
|
||||
async readBinary(file: NodeFile): Promise<ArrayBuffer> {
|
||||
const buffer = await fs.readFile(this.resolvePath(file.path));
|
||||
// Same correction as read() — ensure stat.size matches actual byte length.
|
||||
file.stat.size = buffer.length;
|
||||
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
@@ -66,8 +73,8 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
||||
path: p as any,
|
||||
stat: {
|
||||
size: stat.size,
|
||||
mtime: stat.mtimeMs,
|
||||
ctime: stat.ctimeMs,
|
||||
mtime: Math.floor(stat.mtimeMs),
|
||||
ctime: Math.floor(stat.ctimeMs),
|
||||
type: "file",
|
||||
},
|
||||
};
|
||||
@@ -89,8 +96,8 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
||||
path: p as any,
|
||||
stat: {
|
||||
size: stat.size,
|
||||
mtime: stat.mtimeMs,
|
||||
ctime: stat.ctimeMs,
|
||||
mtime: Math.floor(stat.mtimeMs),
|
||||
ctime: Math.floor(stat.ctimeMs),
|
||||
type: "file",
|
||||
},
|
||||
};
|
||||
|
||||
312
src/apps/cli/commands/daemonCommand.unit.spec.ts
Normal file
312
src/apps/cli/commands/daemonCommand.unit.spec.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { runCommand } from "./runCommand";
|
||||
import type { CLIOptions } from "./types";
|
||||
|
||||
// Mock performFullScan so daemon tests don't require a real CouchDB connection.
|
||||
vi.mock("@lib/serviceFeatures/offlineScanner", () => ({
|
||||
performFullScan: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
// Mock UnresolvedErrorManager to avoid event-hub side effects.
|
||||
vi.mock("@lib/services/base/UnresolvedErrorManager", () => ({
|
||||
UnresolvedErrorManager: class UnresolvedErrorManager {
|
||||
showError() {}
|
||||
clearError() {}
|
||||
clearErrors() {}
|
||||
},
|
||||
}));
|
||||
|
||||
import * as offlineScanner from "@lib/serviceFeatures/offlineScanner";
|
||||
|
||||
function createCoreMock() {
|
||||
return {
|
||||
services: {
|
||||
control: {
|
||||
activated: Promise.resolve(),
|
||||
applySettings: vi.fn(async () => {}),
|
||||
},
|
||||
setting: {
|
||||
applyPartial: vi.fn(async () => {}),
|
||||
currentSettings: vi.fn(() => ({ liveSync: true, syncOnStart: false })),
|
||||
},
|
||||
replication: {
|
||||
replicate: vi.fn(async () => true),
|
||||
},
|
||||
appLifecycle: {
|
||||
onUnload: {
|
||||
addHandler: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
serviceModules: {
|
||||
fileHandler: {
|
||||
dbToStorage: vi.fn(async () => true),
|
||||
storeFileToDB: vi.fn(async () => true),
|
||||
},
|
||||
storageAccess: {
|
||||
readFileAuto: vi.fn(async () => ""),
|
||||
writeFileAuto: vi.fn(async () => {}),
|
||||
},
|
||||
databaseFileAccess: {
|
||||
fetch: vi.fn(async () => undefined),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeDaemonOptions(interval?: number): CLIOptions {
|
||||
return {
|
||||
command: "daemon",
|
||||
commandArgs: [],
|
||||
databasePath: "/tmp/vault",
|
||||
verbose: false,
|
||||
force: false,
|
||||
interval,
|
||||
};
|
||||
}
|
||||
|
||||
const baseContext = {
|
||||
vaultPath: "/tmp/vault",
|
||||
settingsPath: "/tmp/vault/.livesync/settings.json",
|
||||
originalSyncSettings: {
|
||||
liveSync: true,
|
||||
syncOnStart: false,
|
||||
periodicReplication: false,
|
||||
syncOnSave: false,
|
||||
syncOnEditorSave: false,
|
||||
syncOnFileOpen: false,
|
||||
syncAfterMerge: false,
|
||||
},
|
||||
} as any;
|
||||
|
||||
describe("daemon command", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("calls performFullScan during startup", async () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
|
||||
expect(offlineScanner.performFullScan).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns false when performFullScan fails", async () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(false);
|
||||
|
||||
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("polling mode: calls setTimeout when interval option is set", async () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||
|
||||
expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
|
||||
// Interval should be in milliseconds (30s → 30000ms)
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 30000);
|
||||
});
|
||||
|
||||
it("polling mode: applies settings with suspendFileWatching=false before setting interval", async () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(10), { ...baseContext, core });
|
||||
|
||||
expect(core.services.setting.applyPartial).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ suspendFileWatching: false }),
|
||||
true
|
||||
);
|
||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("liveSync mode: calls applyPartial and applySettings", async () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
|
||||
expect(core.services.setting.applyPartial).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
...baseContext.originalSyncSettings,
|
||||
suspendFileWatching: false,
|
||||
}),
|
||||
true
|
||||
);
|
||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("liveSync mode: logs warning when both liveSync and syncOnStart are false", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.setting.currentSettings = vi.fn(() => ({
|
||||
liveSync: false,
|
||||
syncOnStart: false,
|
||||
}));
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
|
||||
expect(result).toBe(true);
|
||||
const warningCalls = consoleSpy.mock.calls.filter(
|
||||
(args) => typeof args[0] === "string" && args[0].includes("liveSync and syncOnStart are both disabled")
|
||||
);
|
||||
expect(warningCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("liveSync mode: no warning when liveSync is true", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.setting.currentSettings = vi.fn(() => ({
|
||||
liveSync: true,
|
||||
syncOnStart: false,
|
||||
}));
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
|
||||
const warningCalls = consoleSpy.mock.calls.filter(
|
||||
(args) => typeof args[0] === "string" && args[0].includes("liveSync and syncOnStart are both disabled")
|
||||
);
|
||||
expect(warningCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("calls replicate before performFullScan", async () => {
|
||||
const core = createCoreMock();
|
||||
const callOrder: string[] = [];
|
||||
core.services.replication.replicate = vi.fn(async () => {
|
||||
callOrder.push("replicate");
|
||||
return true;
|
||||
});
|
||||
vi.mocked(offlineScanner.performFullScan).mockImplementation(async () => {
|
||||
callOrder.push("performFullScan");
|
||||
return true;
|
||||
});
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
|
||||
expect(callOrder).toEqual(["replicate", "performFullScan"]);
|
||||
});
|
||||
|
||||
it("returns false when initial replication fails", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replication.replicate = vi.fn(async () => false);
|
||||
vi.mocked(offlineScanner.performFullScan).mockClear();
|
||||
|
||||
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
|
||||
expect(result).toBe(false);
|
||||
// performFullScan should NOT have been called
|
||||
expect(offlineScanner.performFullScan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("polling mode: registers onUnload handler that clears timeout", async () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(10), { ...baseContext, core });
|
||||
|
||||
// onUnload handler should have been registered
|
||||
expect(core.services.appLifecycle.onUnload.addHandler).toHaveBeenCalledTimes(1);
|
||||
const handler = core.services.appLifecycle.onUnload.addHandler.mock.calls[0][0];
|
||||
|
||||
// Get the timeout ID that was created
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||
await handler();
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("polling backoff: interval escalates on failure, caps at 300000ms, then halves on recovery", async () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// startup replicate (call 1) succeeds; poll calls 2–7 fail; call 8 succeeds.
|
||||
let callCount = 0;
|
||||
core.services.replication.replicate = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) return true; // initial startup replicate
|
||||
if (callCount <= 7) throw new Error("network failure");
|
||||
return true; // recovery
|
||||
});
|
||||
|
||||
const baseMs = 30 * 1000;
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||
|
||||
// After runCommand returns the first setTimeout has been scheduled.
|
||||
// setTimeoutSpy.mock.calls[0] is the initial schedule (baseMs).
|
||||
expect(setTimeoutSpy.mock.calls[0][1]).toBe(baseMs);
|
||||
|
||||
// Advance through 6 failure polls. After each failure the next setTimeout
|
||||
// should be scheduled with a larger (or capped) interval.
|
||||
// formula: min(base * 2^n, 300000). base=30000ms.
|
||||
// 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
|
||||
const expectedIntervals = [
|
||||
baseMs * 2, // after failure 1: 60000
|
||||
baseMs * 4, // after failure 2: 120000
|
||||
baseMs * 8, // after failure 3: 240000
|
||||
300_000, // after failure 4 (would be 480000, capped)
|
||||
300_000, // after failure 5 (cap)
|
||||
300_000, // after failure 6 (cap)
|
||||
];
|
||||
|
||||
for (const expected of expectedIntervals) {
|
||||
const prevCallCount = setTimeoutSpy.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(setTimeoutSpy.mock.calls[prevCallCount - 1][1] as number);
|
||||
const newCallCount = setTimeoutSpy.mock.calls.length;
|
||||
expect(newCallCount).toBeGreaterThan(prevCallCount);
|
||||
expect(setTimeoutSpy.mock.calls[newCallCount - 1][1]).toBe(expected);
|
||||
}
|
||||
|
||||
// Now trigger the success poll — interval should halve each time toward base.
|
||||
// After failure 6, consecutiveFailures=6, currentIntervalMs=300000.
|
||||
// On success: consecutiveFailures=5, currentIntervalMs=150000.
|
||||
const prevCallCount = setTimeoutSpy.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(setTimeoutSpy.mock.calls[prevCallCount - 1][1] as number);
|
||||
const afterSuccessCallCount = setTimeoutSpy.mock.calls.length;
|
||||
expect(afterSuccessCallCount).toBeGreaterThan(prevCallCount);
|
||||
// The interval after one success should be halved (300000 / 2 = 150000).
|
||||
expect(setTimeoutSpy.mock.calls[afterSuccessCallCount - 1][1]).toBe(150_000);
|
||||
});
|
||||
|
||||
it("polling error handling: replicate rejection is caught and console.error is called", async () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// Make replicate succeed on the initial call (startup), then fail on the poll.
|
||||
let callCount = 0;
|
||||
core.services.replication.replicate = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) return true; // startup replicate
|
||||
throw new Error("network failure");
|
||||
});
|
||||
|
||||
const intervalMs = 30 * 1000;
|
||||
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||
|
||||
// Advance time to trigger the first poll callback and flush its async work.
|
||||
await vi.advanceTimersByTimeAsync(intervalMs);
|
||||
|
||||
// No unhandled rejection — the error was caught internally.
|
||||
const errorCalls = consoleSpy.mock.calls.filter(
|
||||
(args) => typeof args[0] === "string" && args[0].includes("Poll error")
|
||||
);
|
||||
expect(errorCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -32,10 +32,15 @@ function validateP2PSettings(core: LiveSyncBaseCore<ServiceContext, any>) {
|
||||
settings.P2P_IsHeadless = true;
|
||||
}
|
||||
|
||||
function createReplicator(core: LiveSyncBaseCore<ServiceContext, any>): LiveSyncTrysteroReplicator {
|
||||
async function createReplicator(core: LiveSyncBaseCore<ServiceContext, any>): Promise<LiveSyncTrysteroReplicator> {
|
||||
validateP2PSettings(core);
|
||||
const replicator = new LiveSyncTrysteroReplicator({ services: core.services });
|
||||
addP2PEventHandlers(replicator);
|
||||
const replicator = await core.services.replicator.getNewReplicator();
|
||||
if (!replicator) {
|
||||
throw new Error("Failed to create replicator instance. Ensure P2P is enabled in settings.");
|
||||
}
|
||||
if (!(replicator instanceof LiveSyncTrysteroReplicator)) {
|
||||
throw new Error("Unexpected replicator type. Expected LiveSyncTrysteroReplicator.");
|
||||
}
|
||||
return replicator;
|
||||
}
|
||||
|
||||
@@ -49,7 +54,7 @@ export async function collectPeers(
|
||||
core: LiveSyncBaseCore<ServiceContext, any>,
|
||||
timeoutSec: number
|
||||
): Promise<CLIP2PPeer[]> {
|
||||
const replicator = createReplicator(core);
|
||||
const replicator = await createReplicator(core);
|
||||
await replicator.open();
|
||||
try {
|
||||
await delay(timeoutSec * 1000);
|
||||
@@ -79,7 +84,7 @@ export async function syncWithPeer(
|
||||
peerToken: string,
|
||||
timeoutSec: number
|
||||
): Promise<CLIP2PPeer> {
|
||||
const replicator = createReplicator(core);
|
||||
const replicator = await createReplicator(core);
|
||||
await replicator.open();
|
||||
try {
|
||||
const timeoutMs = timeoutSec * 1000;
|
||||
@@ -115,7 +120,7 @@ export async function syncWithPeer(
|
||||
}
|
||||
|
||||
export async function openP2PHost(core: LiveSyncBaseCore<ServiceContext, any>): Promise<LiveSyncTrysteroReplicator> {
|
||||
const replicator = createReplicator(core);
|
||||
const replicator = await createReplicator(core);
|
||||
await replicator.open();
|
||||
return replicator;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as path from "path";
|
||||
import { decodeSettingsFromSetupURI } from "@lib/API/processSetting";
|
||||
import { configURIBase } from "@lib/common/models/shared.const";
|
||||
import { DEFAULT_SETTINGS, type FilePathWithPrefix, type ObsidianLiveSyncSettings } from "@lib/common/types";
|
||||
import { ConnectionStringParser } from "@lib/common/ConnectionString";
|
||||
import { activateRemoteConfiguration, createRemoteConfigurationId } from "@lib/serviceFeatures/remoteConfig";
|
||||
import { stripAllPrefixes } from "@lib/string_and_binary/path";
|
||||
import type { CLICommandContext, CLIOptions } from "./types";
|
||||
import { promptForPassphrase, readStdinAsUtf8, toArrayBuffer, toDatabaseRelativePath } from "./utils";
|
||||
@@ -10,11 +12,115 @@ import { collectPeers, openP2PHost, parseTimeoutSeconds, syncWithPeer } from "./
|
||||
import { performFullScan } from "@lib/serviceFeatures/offlineScanner";
|
||||
import { UnresolvedErrorManager } from "@lib/services/base/UnresolvedErrorManager";
|
||||
|
||||
function redactConnectionString(uri: string): string {
|
||||
return uri.replace(/\/\/([^@/]+)@/u, "//***@");
|
||||
}
|
||||
|
||||
export async function runCommand(options: CLIOptions, context: CLICommandContext): Promise<boolean> {
|
||||
const { databasePath, core, settingsPath } = context;
|
||||
|
||||
await core.services.control.activated;
|
||||
if (options.command === "daemon") {
|
||||
const log = (msg: unknown) => console.error(`[Daemon] ${msg}`);
|
||||
|
||||
// Skip the config mismatch dialog — the daemon cannot resolve it interactively
|
||||
// and the default "Dismiss" action would block replication. The daemon should
|
||||
// accept whatever configuration the remote has.
|
||||
await core.services.setting.applyPartial({ disableCheckingConfigMismatch: true }, true);
|
||||
|
||||
// 1. Replicate CouchDB → local PouchDB so the mirror scan has content to work with.
|
||||
log("Replicating from CouchDB...");
|
||||
const replResult = await core.services.replication.replicate(true);
|
||||
if (!replResult) {
|
||||
console.error("[Daemon] Initial CouchDB replication failed, cannot continue");
|
||||
return false;
|
||||
}
|
||||
log("CouchDB replication complete");
|
||||
|
||||
// 2. Mirror scan to reconcile PouchDB ↔ local filesystem.
|
||||
const errorManager = new UnresolvedErrorManager(core.services.appLifecycle);
|
||||
log("Running mirror scan...");
|
||||
const scanOk = await performFullScan(core as any, log, errorManager, false, true);
|
||||
if (!scanOk) {
|
||||
console.error("[Daemon] Mirror scan failed, cannot continue");
|
||||
return false;
|
||||
}
|
||||
log("Mirror scan complete");
|
||||
|
||||
// 3. Re-enable sync.
|
||||
const restoreSyncSettings = async () => {
|
||||
await core.services.setting.applyPartial(
|
||||
{
|
||||
...context.originalSyncSettings,
|
||||
suspendFileWatching: false,
|
||||
},
|
||||
true
|
||||
);
|
||||
// applySettings fires the full lifecycle: onSuspending → onResumed.
|
||||
// ModuleReplicatorCouchDB starts continuous replication on onResumed
|
||||
// via fireAndForget.
|
||||
await core.services.control.applySettings();
|
||||
// Lifecycle events (onSuspending) may re-enable suspension flags.
|
||||
// Clear them explicitly after the lifecycle completes. applyPartial
|
||||
// with true is a direct store write — it does not re-trigger lifecycle.
|
||||
await core.services.setting.applyPartial(
|
||||
{
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
},
|
||||
true
|
||||
);
|
||||
};
|
||||
if (options.interval) {
|
||||
log(`Polling mode: syncing every ${options.interval}s`);
|
||||
await restoreSyncSettings();
|
||||
const baseIntervalMs = options.interval * 1000;
|
||||
let currentIntervalMs = baseIntervalMs;
|
||||
let consecutiveFailures = 0;
|
||||
const maxIntervalMs = 5 * 60 * 1000; // 5 minutes cap
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
await core.services.replication.replicate(true);
|
||||
if (consecutiveFailures > 0) {
|
||||
consecutiveFailures--;
|
||||
currentIntervalMs = Math.max(currentIntervalMs / 2, baseIntervalMs);
|
||||
log(`Replication recovered`);
|
||||
}
|
||||
} catch (err) {
|
||||
consecutiveFailures++;
|
||||
currentIntervalMs = Math.min(baseIntervalMs * Math.pow(2, consecutiveFailures), maxIntervalMs);
|
||||
console.error(`[Daemon] Poll error (${consecutiveFailures} consecutive):`, err);
|
||||
if (consecutiveFailures >= 5) {
|
||||
console.error(
|
||||
`[Daemon] Warning: ${consecutiveFailures} consecutive failures, backing off to ${Math.round(currentIntervalMs / 1000)}s`
|
||||
);
|
||||
}
|
||||
}
|
||||
pollTimer = setTimeout(poll, currentIntervalMs);
|
||||
};
|
||||
let pollTimer: ReturnType<typeof setTimeout> = setTimeout(poll, currentIntervalMs);
|
||||
core.services.appLifecycle.onUnload.addHandler(async () => {
|
||||
clearTimeout(pollTimer);
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
log("LiveSync mode: restoring sync settings and starting _changes feed");
|
||||
await restoreSyncSettings();
|
||||
// The applySettings() lifecycle fires onResumed → ModuleReplicatorCouchDB which
|
||||
// starts continuous replication via fireAndForget(openReplication). Don't call
|
||||
// openReplication directly — it races with the handler and causes dedup/termination.
|
||||
log("LiveSync active");
|
||||
const currentSettings = core.services.setting.currentSettings();
|
||||
if (!currentSettings.liveSync && !currentSettings.syncOnStart) {
|
||||
console.error(
|
||||
"[Daemon] Warning: liveSync and syncOnStart are both disabled in settings. " +
|
||||
"No sync will occur. Set liveSync=true in your settings file for continuous sync, " +
|
||||
"or use --interval for polling mode."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -83,8 +189,8 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
console.log(`[Command] push ${sourcePath} -> ${destinationDatabasePath}`);
|
||||
|
||||
await core.serviceModules.storageAccess.writeFileAuto(destinationDatabasePath, toArrayBuffer(sourceData), {
|
||||
mtime: sourceStat.mtimeMs,
|
||||
ctime: sourceStat.ctimeMs,
|
||||
mtime: Math.floor(sourceStat.mtimeMs),
|
||||
ctime: Math.floor(sourceStat.ctimeMs),
|
||||
});
|
||||
const destinationPathWithPrefix = destinationDatabasePath as FilePathWithPrefix;
|
||||
const stored = await core.serviceModules.fileHandler.storeFileToDB(destinationPathWithPrefix, true);
|
||||
@@ -369,5 +475,206 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
return await performFullScan(core as any, log, errorManager, false, true);
|
||||
}
|
||||
|
||||
if (options.command === "remote-add") {
|
||||
if (options.commandArgs.length < 2) {
|
||||
throw new Error("remote-add requires two arguments: <name> <connstr>");
|
||||
}
|
||||
const name = options.commandArgs[0].trim();
|
||||
const connectionString = options.commandArgs[1].trim();
|
||||
if (!name) {
|
||||
throw new Error("remote-add requires a non-empty name");
|
||||
}
|
||||
if (!connectionString) {
|
||||
throw new Error("remote-add requires a non-empty connection string");
|
||||
}
|
||||
|
||||
const parsed = ConnectionStringParser.parse(connectionString);
|
||||
const canonicalUri = ConnectionStringParser.serialize(parsed);
|
||||
const id = createRemoteConfigurationId();
|
||||
let activated = false;
|
||||
|
||||
await core.services.setting.updateSettings((currentSettings) => {
|
||||
currentSettings.remoteConfigurations ||= {};
|
||||
currentSettings.remoteConfigurations[id] = {
|
||||
id,
|
||||
name,
|
||||
uri: canonicalUri,
|
||||
isEncrypted: false,
|
||||
};
|
||||
if (!currentSettings.activeConfigurationId) {
|
||||
currentSettings.activeConfigurationId = id;
|
||||
const applied = activateRemoteConfiguration(currentSettings, id);
|
||||
activated = applied !== false;
|
||||
}
|
||||
return currentSettings;
|
||||
}, true);
|
||||
|
||||
if (activated) {
|
||||
await core.services.control.applySettings();
|
||||
}
|
||||
|
||||
process.stdout.write(`${id}\t${name}\t${redactConnectionString(canonicalUri)}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "remote-rm") {
|
||||
if (options.commandArgs.length < 1) {
|
||||
throw new Error("remote-rm requires one argument: <remote-id>");
|
||||
}
|
||||
const id = options.commandArgs[0].trim();
|
||||
if (!id) {
|
||||
throw new Error("remote-rm requires a non-empty remote-id");
|
||||
}
|
||||
|
||||
const current = core.services.setting.currentSettings();
|
||||
if (!current.remoteConfigurations?.[id]) {
|
||||
process.stderr.write(`[Info] Remote configuration not found: ${id}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
let switchedActive = false;
|
||||
await core.services.setting.updateSettings((currentSettings) => {
|
||||
const configs = currentSettings.remoteConfigurations || {};
|
||||
delete configs[id];
|
||||
currentSettings.remoteConfigurations = configs;
|
||||
|
||||
if (currentSettings.activeConfigurationId === id) {
|
||||
const nextActiveId = Object.keys(configs)[0] || "";
|
||||
currentSettings.activeConfigurationId = nextActiveId;
|
||||
switchedActive = nextActiveId !== "";
|
||||
if (nextActiveId !== "") {
|
||||
activateRemoteConfiguration(currentSettings, nextActiveId);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSettings.P2P_ActiveRemoteConfigurationId === id) {
|
||||
currentSettings.P2P_ActiveRemoteConfigurationId = "";
|
||||
}
|
||||
|
||||
return currentSettings;
|
||||
}, true);
|
||||
|
||||
if (switchedActive) {
|
||||
await core.services.control.applySettings();
|
||||
}
|
||||
|
||||
console.error(`[Command] remote-rm ${id}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "remote-ls") {
|
||||
const settings = core.services.setting.currentSettings();
|
||||
const configs = Object.values(settings.remoteConfigurations || {});
|
||||
configs.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
if (configs.length === 0) {
|
||||
process.stderr.write("[Info] No remote configurations found.\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
const lines = configs.map((config) => {
|
||||
const status = config.id === settings.activeConfigurationId ? "active" : "inactive";
|
||||
return `${config.id}\t${config.name}\t${status}\t${redactConnectionString(config.uri)}`;
|
||||
});
|
||||
process.stdout.write(lines.join("\n") + "\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "remote-export") {
|
||||
if (options.commandArgs.length < 1) {
|
||||
throw new Error("remote-export requires one argument: <remote-id>");
|
||||
}
|
||||
const id = options.commandArgs[0].trim();
|
||||
if (!id) {
|
||||
throw new Error("remote-export requires a non-empty remote-id");
|
||||
}
|
||||
|
||||
const config = core.services.setting.currentSettings().remoteConfigurations?.[id];
|
||||
if (!config) {
|
||||
process.stderr.write(`[Info] Remote configuration not found: ${id}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
process.stdout.write(`${config.uri}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "remote-set") {
|
||||
if (options.commandArgs.length < 2) {
|
||||
throw new Error("remote-set requires two arguments: <remote-id> <connstr>");
|
||||
}
|
||||
const id = options.commandArgs[0].trim();
|
||||
const connectionString = options.commandArgs[1].trim();
|
||||
if (!id) {
|
||||
throw new Error("remote-set requires a non-empty remote-id");
|
||||
}
|
||||
if (!connectionString) {
|
||||
throw new Error("remote-set requires a non-empty connection string");
|
||||
}
|
||||
|
||||
const parsed = ConnectionStringParser.parse(connectionString);
|
||||
const canonicalUri = ConnectionStringParser.serialize(parsed);
|
||||
let switchedActive = false;
|
||||
|
||||
await core.services.setting.updateSettings((currentSettings) => {
|
||||
const config = currentSettings.remoteConfigurations?.[id];
|
||||
if (!config) {
|
||||
return currentSettings;
|
||||
}
|
||||
config.uri = canonicalUri;
|
||||
|
||||
if (currentSettings.activeConfigurationId === id) {
|
||||
const activated = activateRemoteConfiguration(currentSettings, id);
|
||||
switchedActive = activated !== false;
|
||||
if (activated) {
|
||||
return activated;
|
||||
}
|
||||
}
|
||||
return currentSettings;
|
||||
}, true);
|
||||
|
||||
const updated = core.services.setting.currentSettings().remoteConfigurations?.[id];
|
||||
if (!updated) {
|
||||
process.stderr.write(`[Info] Remote configuration not found: ${id}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (switchedActive) {
|
||||
await core.services.control.applySettings();
|
||||
}
|
||||
|
||||
console.error(`[Command] remote-set ${id}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "remote-activate") {
|
||||
if (options.commandArgs.length < 1) {
|
||||
throw new Error("remote-activate requires one argument: <remote-id>");
|
||||
}
|
||||
const id = options.commandArgs[0].trim();
|
||||
if (!id) {
|
||||
throw new Error("remote-activate requires a non-empty remote-id");
|
||||
}
|
||||
|
||||
let switched = false;
|
||||
await core.services.setting.updateSettings((currentSettings) => {
|
||||
const activated = activateRemoteConfiguration(currentSettings, id);
|
||||
if (activated) {
|
||||
switched = true;
|
||||
return activated;
|
||||
}
|
||||
return currentSettings;
|
||||
}, true);
|
||||
|
||||
if (!switched) {
|
||||
process.stderr.write(`[Info] Failed to activate remote configuration: ${id}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await core.services.control.applySettings();
|
||||
console.error(`[Command] remote-activate ${id}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported command: ${options.command}`);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import * as processSetting from "@lib/API/processSetting";
|
||||
import { ConnectionStringParser } from "@lib/common/ConnectionString";
|
||||
import { configURIBase } from "@lib/common/models/shared.const";
|
||||
import { DEFAULT_SETTINGS } from "@lib/common/types";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P } from "@lib/common/types";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { runCommand } from "./runCommand";
|
||||
import type { CLIOptions } from "./types";
|
||||
import * as commandUtils from "./utils";
|
||||
|
||||
function createCoreMock() {
|
||||
const liveSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteConfigurations: {},
|
||||
activeConfigurationId: "",
|
||||
P2P_ActiveRemoteConfigurationId: "",
|
||||
} as any;
|
||||
return {
|
||||
services: {
|
||||
control: {
|
||||
@@ -16,6 +23,10 @@ function createCoreMock() {
|
||||
setting: {
|
||||
applyExternalSettings: vi.fn(async () => {}),
|
||||
applyPartial: vi.fn(async () => {}),
|
||||
currentSettings: vi.fn(() => liveSettings),
|
||||
updateSettings: vi.fn(async (updater: any) => {
|
||||
updater(liveSettings);
|
||||
}),
|
||||
},
|
||||
},
|
||||
serviceModules: {
|
||||
@@ -56,6 +67,115 @@ async function createSetupURI(passphrase: string): Promise<string> {
|
||||
return await processSetting.encodeSettingsToSetupURI(settings, passphrase);
|
||||
}
|
||||
|
||||
function captureStdout() {
|
||||
const writes: string[] = [];
|
||||
const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: any) => {
|
||||
writes.push(typeof chunk === "string" ? chunk : String(chunk));
|
||||
return true;
|
||||
});
|
||||
return {
|
||||
spy,
|
||||
lines: () =>
|
||||
writes
|
||||
.join("")
|
||||
.split("\n")
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e.length > 0),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAddedRemoteIdFromLines(lines: string[]): string {
|
||||
// remote-add prints: <id>\t<name>\t<redacted-connstr>
|
||||
const last = lines.length > 0 ? lines[lines.length - 1] : "";
|
||||
return last.split("\t")[0] || "";
|
||||
}
|
||||
|
||||
type ProtocolFixture = {
|
||||
protocol: string;
|
||||
connectionString: string;
|
||||
assertProjectedFields: (settings: any) => void;
|
||||
};
|
||||
|
||||
const protocolFixtures: ProtocolFixture[] = [
|
||||
{
|
||||
protocol: "couchdb",
|
||||
connectionString: ConnectionStringParser.serialize({
|
||||
type: "couchdb",
|
||||
settings: {
|
||||
couchDB_URI: "https://db.example.com:5984",
|
||||
couchDB_USER: "user1",
|
||||
couchDB_PASSWORD: "pass1",
|
||||
couchDB_DBNAME: "vault1",
|
||||
couchDB_CustomHeaders: "",
|
||||
useJWT: false,
|
||||
jwtAlgorithm: "",
|
||||
jwtKey: "",
|
||||
jwtKid: "",
|
||||
jwtSub: "",
|
||||
jwtExpDuration: 5,
|
||||
useRequestAPI: false,
|
||||
},
|
||||
}),
|
||||
assertProjectedFields: (settings) => {
|
||||
expect(settings.remoteType).toBe(REMOTE_COUCHDB);
|
||||
expect(settings.couchDB_URI).toBe("https://db.example.com:5984");
|
||||
expect(settings.couchDB_USER).toBe("user1");
|
||||
expect(settings.couchDB_PASSWORD).toBe("pass1");
|
||||
expect(settings.couchDB_DBNAME).toBe("vault1");
|
||||
},
|
||||
},
|
||||
{
|
||||
protocol: "s3",
|
||||
connectionString: ConnectionStringParser.serialize({
|
||||
type: "s3",
|
||||
settings: {
|
||||
accessKey: "ak",
|
||||
secretKey: "sk",
|
||||
endpoint: "https://s3.example.com",
|
||||
bucket: "bucket-1",
|
||||
region: "ap-northeast-1",
|
||||
bucketPrefix: "vault/",
|
||||
useCustomRequestHandler: true,
|
||||
bucketCustomHeaders: "x-test:1",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
}),
|
||||
assertProjectedFields: (settings) => {
|
||||
expect(settings.remoteType).toBe(REMOTE_MINIO);
|
||||
expect(settings.accessKey).toBe("ak");
|
||||
expect(settings.secretKey).toBe("sk");
|
||||
expect(settings.endpoint).toBe("https://s3.example.com");
|
||||
expect(settings.bucket).toBe("bucket-1");
|
||||
expect(settings.region).toBe("ap-northeast-1");
|
||||
},
|
||||
},
|
||||
{
|
||||
protocol: "p2p",
|
||||
connectionString: ConnectionStringParser.serialize({
|
||||
type: "p2p",
|
||||
settings: {
|
||||
P2P_Enabled: false,
|
||||
P2P_roomID: "room-abc",
|
||||
P2P_passphrase: "pass-123",
|
||||
P2P_relays: "wss://relay.example",
|
||||
P2P_AppID: "self-hosted-livesync",
|
||||
P2P_AutoStart: true,
|
||||
P2P_AutoBroadcast: false,
|
||||
P2P_turnServers: "turn:turn.example:3478",
|
||||
P2P_turnUsername: "turn-user",
|
||||
P2P_turnCredential: "turn-pass",
|
||||
},
|
||||
}),
|
||||
assertProjectedFields: (settings) => {
|
||||
expect(settings.remoteType).toBe(REMOTE_P2P);
|
||||
expect(settings.P2P_roomID).toBe("room-abc");
|
||||
expect(settings.P2P_passphrase).toBe("pass-123");
|
||||
expect(settings.P2P_relays).toBe("wss://relay.example");
|
||||
expect(settings.P2P_AppID).toBe("self-hosted-livesync");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("runCommand abnormal cases", () => {
|
||||
const context = {
|
||||
databasePath: "/tmp/vault",
|
||||
@@ -202,4 +322,254 @@ describe("runCommand abnormal cases", () => {
|
||||
expect(core.services.setting.applyExternalSettings).not.toHaveBeenCalled();
|
||||
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("remote-add stores canonical URI and prints the created id", async () => {
|
||||
const core = createCoreMock();
|
||||
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
|
||||
const result = await runCommand(makeOptions("remote-add", ["my-remote", "sls+https://example.com/db"]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
const settings = core.services.setting.currentSettings();
|
||||
const ids = Object.keys(settings.remoteConfigurations);
|
||||
expect(ids.length).toBe(1);
|
||||
expect(settings.remoteConfigurations[ids[0]].name).toBe("my-remote");
|
||||
expect(settings.remoteConfigurations[ids[0]].uri).toContain("sls+https://example.com/db");
|
||||
expect(settings.activeConfigurationId).toBe(ids[0]);
|
||||
expect(stdout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("remote-activate switches active remote and applies settings", async () => {
|
||||
const core = createCoreMock();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
settings.remoteConfigurations.r1 = {
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
uri: "sls+https://example.com/db1",
|
||||
isEncrypted: false,
|
||||
};
|
||||
settings.remoteConfigurations.r2 = {
|
||||
id: "r2",
|
||||
name: "R2",
|
||||
uri: "sls+https://example.com/db2",
|
||||
isEncrypted: false,
|
||||
};
|
||||
settings.activeConfigurationId = "r1";
|
||||
|
||||
const result = await runCommand(makeOptions("remote-activate", ["r2"]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(settings.activeConfigurationId).toBe("r2");
|
||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("remote-rm removes active remote and promotes first remaining", async () => {
|
||||
const core = createCoreMock();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
settings.remoteConfigurations.r1 = {
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
uri: "sls+https://example.com/db1",
|
||||
isEncrypted: false,
|
||||
};
|
||||
settings.remoteConfigurations.r2 = {
|
||||
id: "r2",
|
||||
name: "R2",
|
||||
uri: "sls+https://example.com/db2",
|
||||
isEncrypted: false,
|
||||
};
|
||||
settings.activeConfigurationId = "r1";
|
||||
|
||||
const result = await runCommand(makeOptions("remote-rm", ["r1"]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(settings.remoteConfigurations.r1).toBeUndefined();
|
||||
expect(settings.activeConfigurationId).toBe("r2");
|
||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("remote-export prints the exact stored connection string", async () => {
|
||||
const core = createCoreMock();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
settings.remoteConfigurations.r1 = {
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
uri: "sls+https://example.com/db?db=vault",
|
||||
isEncrypted: false,
|
||||
};
|
||||
const stdout = captureStdout();
|
||||
|
||||
const result = await runCommand(makeOptions("remote-export", ["r1"]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
const outLines = stdout.lines();
|
||||
expect(outLines.length > 0 ? outLines[outLines.length - 1] : "").toBe("sls+https://example.com/db?db=vault");
|
||||
expect(stdout.spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("remote-set updates URI and applies settings when target is active", async () => {
|
||||
const core = createCoreMock();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
settings.remoteConfigurations.r1 = {
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
uri: "sls+https://old.example/db",
|
||||
isEncrypted: false,
|
||||
};
|
||||
settings.activeConfigurationId = "r1";
|
||||
|
||||
const result = await runCommand(makeOptions("remote-set", ["r1", "sls+https://new.example/db"]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(settings.remoteConfigurations.r1.uri).toContain("sls+https://new.example/db");
|
||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(protocolFixtures)(
|
||||
"remote-activate projects effective settings for $protocol",
|
||||
async ({ connectionString, assertProjectedFields }) => {
|
||||
const core = createCoreMock();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
settings.remoteConfigurations.r1 = {
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
uri: "sls+https://old.example/?db=old",
|
||||
isEncrypted: false,
|
||||
};
|
||||
settings.remoteConfigurations.r2 = {
|
||||
id: "r2",
|
||||
name: "R2",
|
||||
uri: connectionString,
|
||||
isEncrypted: false,
|
||||
};
|
||||
settings.activeConfigurationId = "r1";
|
||||
|
||||
const result = await runCommand(makeOptions("remote-activate", ["r2"]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(settings.activeConfigurationId).toBe("r2");
|
||||
assertProjectedFields(settings);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(protocolFixtures)(
|
||||
"remote-set projects effective settings for active remote ($protocol)",
|
||||
async ({ connectionString, assertProjectedFields }) => {
|
||||
const core = createCoreMock();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
settings.remoteConfigurations.r1 = {
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
uri: "sls+https://old.example/?db=old",
|
||||
isEncrypted: false,
|
||||
};
|
||||
settings.activeConfigurationId = "r1";
|
||||
|
||||
const result = await runCommand(makeOptions("remote-set", ["r1", connectionString]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
assertProjectedFields(settings);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(protocolFixtures)(
|
||||
"remote-rm projects promoted active remote effective settings for $protocol",
|
||||
async ({ connectionString, assertProjectedFields }) => {
|
||||
const core = createCoreMock();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
settings.remoteConfigurations.r1 = {
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
uri: "sls+https://old.example/?db=old",
|
||||
isEncrypted: false,
|
||||
};
|
||||
settings.remoteConfigurations.r2 = {
|
||||
id: "r2",
|
||||
name: "R2",
|
||||
uri: connectionString,
|
||||
isEncrypted: false,
|
||||
};
|
||||
settings.activeConfigurationId = "r1";
|
||||
|
||||
const result = await runCommand(makeOptions("remote-rm", ["r1"]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(settings.activeConfigurationId).toBe("r2");
|
||||
assertProjectedFields(settings);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
["couchdb", "sls+https://user:pass@example.com:5984/?db=vault"] as const,
|
||||
[
|
||||
"s3",
|
||||
"sls+s3://ak:sk@example.com/?endpoint=https%3A%2F%2Fs3.example.com&bucket=my-bucket®ion=ap-northeast-1",
|
||||
] as const,
|
||||
[
|
||||
"p2p",
|
||||
"sls+p2p://room-abc?passphrase=pass-123&relays=wss%3A%2F%2Frelay.example&appId=self-hosted-livesync",
|
||||
] as const,
|
||||
])("remote command round-trip works for %s", async (_protocol, initialConnStr) => {
|
||||
const core = createCoreMock();
|
||||
|
||||
const addOut = captureStdout();
|
||||
const addResult = await runCommand(makeOptions("remote-add", ["rt", initialConnStr]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
expect(addResult).toBe(true);
|
||||
const remoteId = parseAddedRemoteIdFromLines(addOut.lines());
|
||||
expect(remoteId).not.toBe("");
|
||||
|
||||
const export1Out = captureStdout();
|
||||
const export1Result = await runCommand(makeOptions("remote-export", [remoteId]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
expect(export1Result).toBe(true);
|
||||
const export1Lines = export1Out.lines();
|
||||
const exported1 = export1Lines.length > 0 ? export1Lines[export1Lines.length - 1] : "";
|
||||
expect(exported1).toBe(ConnectionStringParser.serialize(ConnectionStringParser.parse(initialConnStr)));
|
||||
|
||||
const roundTripInput = ConnectionStringParser.serialize(ConnectionStringParser.parse(exported1));
|
||||
const setResult = await runCommand(makeOptions("remote-set", [remoteId, roundTripInput]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
expect(setResult).toBe(true);
|
||||
|
||||
const export2Out = captureStdout();
|
||||
const export2Result = await runCommand(makeOptions("remote-export", [remoteId]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
expect(export2Result).toBe(true);
|
||||
const export2Lines = export2Out.lines();
|
||||
const exported2 = export2Lines.length > 0 ? export2Lines[export2Lines.length - 1] : "";
|
||||
expect(exported2).toBe(roundTripInput);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LiveSyncBaseCore } from "../../../LiveSyncBaseCore";
|
||||
import { ServiceContext } from "@lib/services/base/ServiceBase";
|
||||
import type { ObsidianLiveSyncSettings } from "@lib/common/types";
|
||||
|
||||
export type CLICommand =
|
||||
| "daemon"
|
||||
@@ -19,6 +20,12 @@ export type CLICommand =
|
||||
| "rm"
|
||||
| "resolve"
|
||||
| "mirror"
|
||||
| "remote-add"
|
||||
| "remote-rm"
|
||||
| "remote-ls"
|
||||
| "remote-export"
|
||||
| "remote-set"
|
||||
| "remote-activate"
|
||||
| "init-settings";
|
||||
|
||||
export interface CLIOptions {
|
||||
@@ -29,15 +36,27 @@ export interface CLIOptions {
|
||||
force?: boolean;
|
||||
command: CLICommand;
|
||||
commandArgs: string[];
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
export interface CLICommandContext {
|
||||
databasePath: string;
|
||||
core: LiveSyncBaseCore<ServiceContext, any>;
|
||||
settingsPath: string;
|
||||
originalSyncSettings: Pick<
|
||||
ObsidianLiveSyncSettings,
|
||||
| "liveSync"
|
||||
| "syncOnStart"
|
||||
| "periodicReplication"
|
||||
| "syncOnSave"
|
||||
| "syncOnEditorSave"
|
||||
| "syncOnFileOpen"
|
||||
| "syncAfterMerge"
|
||||
>;
|
||||
}
|
||||
|
||||
export const VALID_COMMANDS = new Set([
|
||||
"daemon",
|
||||
"sync",
|
||||
"p2p-peers",
|
||||
"p2p-sync",
|
||||
@@ -54,5 +73,11 @@ export const VALID_COMMANDS = new Set([
|
||||
"rm",
|
||||
"resolve",
|
||||
"mirror",
|
||||
"remote-add",
|
||||
"remote-rm",
|
||||
"remote-ls",
|
||||
"remote-export",
|
||||
"remote-set",
|
||||
"remote-activate",
|
||||
"init-settings",
|
||||
] as const);
|
||||
|
||||
187
src/apps/cli/deploy/install.sh
Executable file
187
src/apps/cli/deploy/install.sh
Executable file
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env bash
|
||||
# install.sh — install livesync-cli as a systemd service
|
||||
#
|
||||
# Usage:
|
||||
# install.sh [--user] [--system] [--vault <path>] [--interval <N>]
|
||||
#
|
||||
# Defaults: user install, prompts for vault path if not supplied.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
|
||||
CLI_DIR="$REPO_ROOT/src/apps/cli"
|
||||
SERVICE_TEMPLATE="$SCRIPT_DIR/livesync-cli.service"
|
||||
|
||||
# ── Argument parsing ────────────────────────────────────────────────────────
|
||||
INSTALL_MODE="user"
|
||||
VAULT_PATH=""
|
||||
INTERVAL=""
|
||||
FORCE=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--user)
|
||||
INSTALL_MODE="user"
|
||||
shift
|
||||
;;
|
||||
--system)
|
||||
INSTALL_MODE="system"
|
||||
shift
|
||||
;;
|
||||
--vault)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo "Error: --vault requires a path argument" >&2
|
||||
exit 1
|
||||
fi
|
||||
VAULT_PATH="$2"
|
||||
shift 2
|
||||
;;
|
||||
--interval)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo "Error: --interval requires a numeric argument" >&2
|
||||
exit 1
|
||||
fi
|
||||
INTERVAL="$2"
|
||||
if ! [[ "$INTERVAL" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "Error: --interval requires a positive integer, got '$INTERVAL'" >&2
|
||||
exit 1
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
--force|-f)
|
||||
FORCE=1
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
cat <<EOF
|
||||
Usage: install.sh [--user|--system] [--vault <path>] [--interval <N>] [--force]
|
||||
|
||||
--user Install as a user systemd service (default, ~/.config/systemd/user/)
|
||||
--system Install as a system systemd service (/etc/systemd/system/)
|
||||
--vault Path to the vault directory (prompted if omitted)
|
||||
--interval Poll CouchDB every N seconds instead of using the _changes feed
|
||||
--force Overwrite existing service unit without prompting
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Error: Unknown argument: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Vault path ──────────────────────────────────────────────────────────────
|
||||
if [[ -z "$VAULT_PATH" ]]; then
|
||||
if [ ! -t 0 ]; then
|
||||
echo "Error: --vault is required in non-interactive mode" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'Vault path: '
|
||||
read -r VAULT_PATH
|
||||
fi
|
||||
|
||||
_orig_vault="$VAULT_PATH"
|
||||
if ! VAULT_PATH="$(cd -- "$VAULT_PATH" 2>/dev/null && pwd)"; then
|
||||
echo "Error: vault directory does not exist: $_orig_vault" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[INFO] Vault: $VAULT_PATH"
|
||||
echo "[INFO] Install mode: $INSTALL_MODE"
|
||||
|
||||
# ── Build ────────────────────────────────────────────────────────────────────
|
||||
echo "[INFO] Building CLI from $REPO_ROOT..."
|
||||
(cd "$REPO_ROOT" && npm install --silent)
|
||||
(cd "$CLI_DIR" && npm run build)
|
||||
|
||||
BUILT_CJS="$CLI_DIR/dist/index.cjs"
|
||||
if [[ ! -f "$BUILT_CJS" ]]; then
|
||||
echo "Error: build output not found: $BUILT_CJS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Install binary ───────────────────────────────────────────────────────────
|
||||
if [[ "$INSTALL_MODE" == "user" ]]; then
|
||||
BIN_DIR="$HOME/.local/bin"
|
||||
UNIT_DIR="$HOME/.config/systemd/user"
|
||||
SYSTEMCTL_FLAGS="--user"
|
||||
else
|
||||
BIN_DIR="/usr/local/bin"
|
||||
UNIT_DIR="/etc/systemd/system"
|
||||
SYSTEMCTL_FLAGS=""
|
||||
fi
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
|
||||
LIVESYNC_BIN="$BIN_DIR/livesync-cli"
|
||||
LIVESYNC_JS="$BIN_DIR/livesync-cli.js"
|
||||
|
||||
# Copy the CJS bundle so the wrapper is self-contained and independent of the
|
||||
# build directory location.
|
||||
cp "$BUILT_CJS" "$LIVESYNC_JS"
|
||||
|
||||
# Write a bash wrapper that invokes node on the installed bundle.
|
||||
cat > "$LIVESYNC_BIN" <<WRAPPER
|
||||
#!/usr/bin/env bash
|
||||
exec node "$LIVESYNC_JS" "\$@"
|
||||
WRAPPER
|
||||
chmod +x "$LIVESYNC_BIN"
|
||||
echo "[INFO] Installed bundle: $LIVESYNC_JS"
|
||||
echo "[INFO] Installed binary: $LIVESYNC_BIN"
|
||||
|
||||
# ── Write systemd unit ───────────────────────────────────────────────────────
|
||||
mkdir -p "$UNIT_DIR"
|
||||
UNIT_PATH="$UNIT_DIR/livesync-cli.service"
|
||||
|
||||
EXEC_START="\"$LIVESYNC_BIN\" \"$VAULT_PATH\""
|
||||
if [[ -n "$INTERVAL" ]]; then
|
||||
EXEC_START="\"$LIVESYNC_BIN\" \"$VAULT_PATH\" --interval $INTERVAL"
|
||||
fi
|
||||
|
||||
# Check for existing service and offer to overwrite.
|
||||
if [[ -f "$UNIT_PATH" ]] && [[ "$FORCE" -eq 0 ]]; then
|
||||
if [ ! -t 0 ]; then
|
||||
echo "Error: service unit already exists at $UNIT_PATH; use --force to overwrite" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'Service unit already exists at %s. Overwrite? [y/N]: ' "$UNIT_PATH"
|
||||
read -r CONFIRM
|
||||
case "$CONFIRM" in
|
||||
[yY]|[yY][eE][sS]) : ;;
|
||||
*)
|
||||
echo "[INFO] Aborted. Existing unit left in place."
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# In awk gsub(), '&' in the replacement means "matched text"; escape any literal '&'
|
||||
# in path variables before passing them as awk replacement strings.
|
||||
AWK_BIN="${LIVESYNC_BIN//&/\\&}"
|
||||
AWK_VAULT="${VAULT_PATH//&/\\&}"
|
||||
awk -v bin="$AWK_BIN" -v vault="$AWK_VAULT" -v exec_start="ExecStart=$EXEC_START" \
|
||||
'/^ExecStart=/ { print exec_start; next } {gsub("LIVESYNC_BIN", bin); gsub("LIVESYNC_VAULT_PATH", vault); print}' \
|
||||
"$SERVICE_TEMPLATE" > "$UNIT_PATH"
|
||||
|
||||
echo "[INFO] Installed unit: $UNIT_PATH"
|
||||
|
||||
# ── Enable service ───────────────────────────────────────────────────────────
|
||||
if ! command -v systemctl >/dev/null 2>&1; then
|
||||
echo "[WARN] systemctl not found — skipping service activation"
|
||||
echo "[INFO] To enable manually, copy $UNIT_PATH to the correct systemd directory and run:"
|
||||
echo " systemctl $SYSTEMCTL_FLAGS daemon-reload"
|
||||
echo " systemctl $SYSTEMCTL_FLAGS enable --now livesync-cli"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
systemctl $SYSTEMCTL_FLAGS daemon-reload
|
||||
# shellcheck disable=SC2086
|
||||
systemctl $SYSTEMCTL_FLAGS enable --now livesync-cli
|
||||
|
||||
echo ""
|
||||
echo "[Done] livesync-cli service installed and started."
|
||||
echo ""
|
||||
# shellcheck disable=SC2086
|
||||
systemctl $SYSTEMCTL_FLAGS status livesync-cli --no-pager || true
|
||||
17
src/apps/cli/deploy/livesync-cli.service
Normal file
17
src/apps/cli/deploy/livesync-cli.service
Normal file
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Self-hosted LiveSync CLI Daemon
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=LIVESYNC_BIN LIVESYNC_VAULT_PATH
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
TimeoutStartSec=300
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
LimitNOFILE=65536
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -8,7 +8,6 @@ import * as path from "path";
|
||||
import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub";
|
||||
import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./services/NodeLocalStorage";
|
||||
import { LiveSyncBaseCore } from "../../LiveSyncBaseCore";
|
||||
import { ModuleReplicatorP2P } from "../../modules/core/ModuleReplicatorP2P";
|
||||
import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules";
|
||||
import { DEFAULT_SETTINGS, LOG_LEVEL_VERBOSE, type LOG_LEVEL, type ObsidianLiveSyncSettings } from "@lib/common/types";
|
||||
import type { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub";
|
||||
@@ -26,6 +25,8 @@ import { VALID_COMMANDS } from "./commands/types";
|
||||
import type { CLICommand, CLIOptions } from "./commands/types";
|
||||
import { getPathFromUXFileInfo } from "@lib/common/typeUtils";
|
||||
import { stripAllPrefixes } from "@lib/string_and_binary/path";
|
||||
import { IgnoreRules } from "./serviceModules/IgnoreRules";
|
||||
import { useP2PReplicatorFeature } from "@/lib/src/replication/trystero/useP2PReplicatorFeature";
|
||||
|
||||
const SETTINGS_FILE = ".livesync/settings.json";
|
||||
ensureGlobalNodeLocalStorage();
|
||||
@@ -43,7 +44,8 @@ Arguments:
|
||||
database-path Path to the local database directory
|
||||
|
||||
Commands:
|
||||
sync Run one replication cycle and exit
|
||||
daemon (default) Run mirror scan then continuously sync CouchDB <-> local filesystem
|
||||
sync Run one replication cycle and exit
|
||||
p2p-peers <timeout> Show discovered peers as [peer]\t<peer-id>\t<peer-name>
|
||||
p2p-sync <peer> <timeout>
|
||||
Sync with the specified peer-id or peer-name
|
||||
@@ -60,24 +62,46 @@ Commands:
|
||||
rm <path> Mark a file as deleted in local database
|
||||
resolve <path> <rev> Resolve conflicts by keeping <rev> and deleting others
|
||||
mirror [vault-path] Mirror database contents to the local file system (vault-path defaults to database-path)
|
||||
remote-add <name> <connstr>
|
||||
Add a remote configuration from a connection string
|
||||
remote-rm <remote-id> Remove a remote configuration by ID
|
||||
remote-ls List stored remote configurations
|
||||
remote-export <remote-id>
|
||||
Export a remote connection string by ID
|
||||
remote-set <remote-id> <connstr>
|
||||
Replace a stored remote connection string by ID
|
||||
remote-activate <remote-id>
|
||||
Activate a stored remote configuration by ID
|
||||
|
||||
Options:
|
||||
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
|
||||
|
||||
Examples:
|
||||
livesync-cli ./my-database sync
|
||||
livesync-cli ./my-database p2p-peers 5
|
||||
livesync-cli ./my-database p2p-sync my-peer-name 15
|
||||
livesync-cli ./my-database p2p-host
|
||||
livesync-cli ./my-database --settings ./custom-settings.json push ./note.md folder/note.md
|
||||
livesync-cli ./my-database pull folder/note.md ./exports/note.md
|
||||
livesync-cli ./my-database pull-rev folder/note.md ./exports/note.old.md 3-abcdef
|
||||
livesync-cli ./my-database setup "obsidian://setuplivesync?settings=..."
|
||||
echo "Hello" | livesync-cli ./my-database put notes/hello.md
|
||||
livesync-cli ./my-database cat notes/hello.md
|
||||
livesync-cli ./my-database cat-rev notes/hello.md 3-abcdef
|
||||
livesync-cli ./my-database ls notes/
|
||||
livesync-cli ./my-database info notes/hello.md
|
||||
livesync-cli ./my-database rm notes/hello.md
|
||||
livesync-cli ./my-database resolve notes/hello.md 3-abcdef
|
||||
livesync-cli init-settings ./data.json
|
||||
livesync-cli ./my-database --verbose
|
||||
livesync-cli ./my-database Run daemon (LiveSync mode)
|
||||
livesync-cli ./my-database --interval 30 Run daemon (polling every 30s)
|
||||
livesync-cli ./my-database sync
|
||||
livesync-cli ./my-database p2p-peers 5
|
||||
livesync-cli ./my-database p2p-sync my-peer-name 15
|
||||
livesync-cli ./my-database p2p-host
|
||||
livesync-cli ./my-database --settings ./custom-settings.json push ./note.md folder/note.md
|
||||
livesync-cli ./my-database pull folder/note.md ./exports/note.md
|
||||
livesync-cli ./my-database pull-rev folder/note.md ./exports/note.old.md 3-abcdef
|
||||
livesync-cli ./my-database setup "obsidian://setuplivesync?settings=..."
|
||||
echo "Hello" | livesync-cli ./my-database put notes/hello.md
|
||||
livesync-cli ./my-database cat notes/hello.md
|
||||
livesync-cli ./my-database cat-rev notes/hello.md 3-abcdef
|
||||
livesync-cli ./my-database ls notes/
|
||||
livesync-cli ./my-database info notes/hello.md
|
||||
livesync-cli ./my-database rm notes/hello.md
|
||||
livesync-cli ./my-database resolve notes/hello.md 3-abcdef
|
||||
livesync-cli ./my-database remote-add my-remote "sls+https://user:pass@example.com/db"
|
||||
livesync-cli ./my-database remote-ls
|
||||
livesync-cli ./my-database remote-export remote-abc123
|
||||
livesync-cli ./my-database remote-set remote-abc123 "sls+s3://ak:sk@example.com/?endpoint=https%3A%2F%2Fs3.example.com&bucket=mybucket"
|
||||
livesync-cli ./my-database remote-activate remote-abc123
|
||||
livesync-cli ./my-database remote-rm remote-abc123
|
||||
livesync-cli init-settings ./data.json
|
||||
livesync-cli ./my-database --verbose
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -94,6 +118,7 @@ export function parseArgs(): CLIOptions {
|
||||
let verbose = false;
|
||||
let debug = false;
|
||||
let force = false;
|
||||
let interval: number | undefined;
|
||||
let command: CLICommand = "daemon";
|
||||
const commandArgs: string[] = [];
|
||||
|
||||
@@ -110,6 +135,21 @@ export function parseArgs(): CLIOptions {
|
||||
settingsPath = args[i];
|
||||
break;
|
||||
}
|
||||
case "--interval":
|
||||
case "-i": {
|
||||
i++;
|
||||
if (!args[i]) {
|
||||
console.error(`Error: Missing value for ${token}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const n = parseInt(args[i], 10);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
console.error(`Error: --interval requires a positive integer, got '${args[i]}'`);
|
||||
process.exit(1);
|
||||
}
|
||||
interval = n;
|
||||
break;
|
||||
}
|
||||
case "--debug":
|
||||
case "-d":
|
||||
// debugging automatically enables verbose logging, as it is intended for debugging issues.
|
||||
@@ -164,6 +204,7 @@ export function parseArgs(): CLIOptions {
|
||||
force,
|
||||
command,
|
||||
commandArgs,
|
||||
interval,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -197,10 +238,16 @@ async function createDefaultSettingsFile(options: CLIOptions) {
|
||||
|
||||
export async function main() {
|
||||
const options = parseArgs();
|
||||
if (options.interval && options.command !== "daemon") {
|
||||
console.error(`Warning: --interval is only used in daemon mode, ignored for '${options.command}'`);
|
||||
}
|
||||
const avoidStdoutNoise =
|
||||
options.command === "cat" ||
|
||||
options.command === "cat-rev" ||
|
||||
options.command === "ls" ||
|
||||
options.command === "remote-add" ||
|
||||
options.command === "remote-ls" ||
|
||||
options.command === "remote-export" ||
|
||||
options.command === "p2p-peers" ||
|
||||
options.command === "info" ||
|
||||
options.command === "rm" ||
|
||||
@@ -248,6 +295,17 @@ export async function main() {
|
||||
infoLog(`Settings: ${settingsPath}`);
|
||||
infoLog("");
|
||||
|
||||
// For daemon and mirror mode, load ignore rules before the core is constructed so that
|
||||
// chokidar's ignored option is populated when beginWatch() fires during onLoad().
|
||||
const watchEnabled = options.command === "daemon";
|
||||
const vaultPath =
|
||||
options.command === "mirror" && options.commandArgs[0] ? path.resolve(options.commandArgs[0]) : databasePath;
|
||||
let ignoreRules: IgnoreRules | undefined;
|
||||
if (options.command === "daemon" || options.command === "mirror") {
|
||||
ignoreRules = new IgnoreRules(vaultPath);
|
||||
await ignoreRules.load();
|
||||
}
|
||||
|
||||
// Create service context and hub
|
||||
const context = new NodeServiceContext(databasePath);
|
||||
const serviceHubInstance = new NodeServiceHub<NodeServiceContext>(databasePath, context);
|
||||
@@ -278,11 +336,14 @@ export async function main() {
|
||||
}
|
||||
console.error(`${prefix} ${message}`);
|
||||
});
|
||||
// Prevent replication result to be processed automatically.
|
||||
serviceHubInstance.replication.processSynchroniseResult.addHandler(async () => {
|
||||
console.error(`[Info] Replication result received, but not processed automatically in CLI mode.`);
|
||||
return await Promise.resolve(true);
|
||||
}, -100);
|
||||
// Prevent replication result from being processed automatically in non-daemon commands.
|
||||
// In daemon mode the default handler must run so changes are applied to the filesystem.
|
||||
if (options.command !== "daemon") {
|
||||
serviceHubInstance.replication.processSynchroniseResult.addHandler(async () => {
|
||||
console.error(`[Info] Replication result received, but not processed automatically in CLI mode.`);
|
||||
return await Promise.resolve(true);
|
||||
}, -100);
|
||||
}
|
||||
|
||||
// Setup settings handlers
|
||||
const settingService = serviceHubInstance.setting;
|
||||
@@ -324,18 +385,13 @@ export async function main() {
|
||||
const core = new LiveSyncBaseCore(
|
||||
serviceHubInstance,
|
||||
(core: LiveSyncBaseCore<NodeServiceContext, any>, serviceHub: InjectableServiceHub<NodeServiceContext>) => {
|
||||
const mirrorVaultPath =
|
||||
options.command === "mirror" && options.commandArgs[0]
|
||||
? path.resolve(options.commandArgs[0])
|
||||
: databasePath;
|
||||
return initialiseServiceModulesCLI(mirrorVaultPath, core, serviceHub);
|
||||
return initialiseServiceModulesCLI(vaultPath, core, serviceHub, ignoreRules, watchEnabled);
|
||||
},
|
||||
(core) => [
|
||||
// No modules need to be registered for P2P replication in CLI. Directly using Replicators in p2p.ts
|
||||
// new ModuleReplicatorP2P(core),
|
||||
],
|
||||
(core) => [],
|
||||
() => [], // No add-ons
|
||||
(core) => {
|
||||
// Register P2P replicator feature.
|
||||
const _replicator = useP2PReplicatorFeature(core);
|
||||
// Add target filter to prevent internal files are handled
|
||||
core.services.vault.isTargetFile.addHandler(async (target) => {
|
||||
const targetPath = stripAllPrefixes(getPathFromUXFileInfo(target));
|
||||
@@ -344,8 +400,25 @@ export async function main() {
|
||||
if (parts.some((part) => part.startsWith("."))) {
|
||||
return await Promise.resolve(false);
|
||||
}
|
||||
// PouchDB LevelDB database directory lives in the vault directory.
|
||||
if (parts[0]?.endsWith("-livesync-v2")) {
|
||||
return await Promise.resolve(false);
|
||||
}
|
||||
return await Promise.resolve(true);
|
||||
}, -1 /* highest priority */);
|
||||
|
||||
// Apply user-defined ignore rules for daemon mode (lower priority, runs after dotfile check).
|
||||
if (ignoreRules) {
|
||||
const rules = ignoreRules;
|
||||
core.services.vault.isTargetFile.addHandler(async (target) => {
|
||||
const targetPath = stripAllPrefixes(getPathFromUXFileInfo(target));
|
||||
if (rules.shouldIgnore(targetPath)) {
|
||||
return false;
|
||||
}
|
||||
// undefined = pass through to next handler in chain
|
||||
return undefined;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -366,6 +439,25 @@ export async function main() {
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
|
||||
// Save the settings file before any lifecycle events can mutate and persist them.
|
||||
// suspendAllSync and other lifecycle hooks clobber sync settings in memory, and
|
||||
// various code paths persist the clobbered state to disk. We restore on shutdown.
|
||||
const settingsBackup = await fs.readFile(settingsPath, "utf-8").catch(() => null!);
|
||||
|
||||
// Restore settings file on any exit to undo lifecycle mutations.
|
||||
// Write to a temp path first so a crash mid-write doesn't leave a truncated file.
|
||||
process.on("exit", () => {
|
||||
if (settingsBackup) {
|
||||
const tmpPath = settingsPath + ".tmp";
|
||||
try {
|
||||
require("fs").writeFileSync(tmpPath, settingsBackup, "utf-8");
|
||||
require("fs").renameSync(tmpPath, settingsPath);
|
||||
} catch (err) {
|
||||
console.error("[Settings] Failed to restore settings on exit:", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start the core
|
||||
try {
|
||||
infoLog(`[Starting] Initializing LiveSync...`);
|
||||
@@ -375,6 +467,18 @@ export async function main() {
|
||||
console.error(`[Error] Failed to initialize LiveSync`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Capture sync settings before suspendAllSync() clobbers them.
|
||||
// Used by daemon mode to restore the correct sync behaviour after the mirror scan.
|
||||
const settingsBeforeSuspend = core.services.setting.currentSettings();
|
||||
const originalSyncSettings = {
|
||||
liveSync: settingsBeforeSuspend.liveSync,
|
||||
syncOnStart: settingsBeforeSuspend.syncOnStart,
|
||||
periodicReplication: settingsBeforeSuspend.periodicReplication,
|
||||
syncOnSave: settingsBeforeSuspend.syncOnSave,
|
||||
syncOnEditorSave: settingsBeforeSuspend.syncOnEditorSave,
|
||||
syncOnFileOpen: settingsBeforeSuspend.syncOnFileOpen,
|
||||
syncAfterMerge: settingsBeforeSuspend.syncAfterMerge,
|
||||
};
|
||||
await core.services.setting.suspendAllSync();
|
||||
await core.services.control.onReady();
|
||||
|
||||
@@ -400,7 +504,7 @@ export async function main() {
|
||||
infoLog("");
|
||||
}
|
||||
|
||||
const result = await runCommand(options, { databasePath, core, settingsPath });
|
||||
const result = await runCommand(options, { databasePath, core, settingsPath, originalSyncSettings });
|
||||
if (!result) {
|
||||
console.error(`[Error] Command '${options.command}' failed`);
|
||||
process.exitCode = 1;
|
||||
@@ -408,7 +512,7 @@ export async function main() {
|
||||
infoLog(`[Done] Command '${options.command}' completed`);
|
||||
}
|
||||
|
||||
if (options.command === "daemon") {
|
||||
if (options.command === "daemon" && result) {
|
||||
// Keep the process running
|
||||
await new Promise(() => {});
|
||||
} else {
|
||||
|
||||
@@ -85,4 +85,117 @@ describe("CLI parseArgs", () => {
|
||||
expect(parsed.command).toBe("p2p-host");
|
||||
expect(parsed.commandArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses remote-add command", () => {
|
||||
process.argv = [
|
||||
"node",
|
||||
"livesync-cli",
|
||||
"./databasePath",
|
||||
"remote-add",
|
||||
"my-remote",
|
||||
"sls+https://user:pass@example.com/db",
|
||||
];
|
||||
const parsed = parseArgs();
|
||||
|
||||
expect(parsed.databasePath).toBe("./databasePath");
|
||||
expect(parsed.command).toBe("remote-add");
|
||||
expect(parsed.commandArgs).toEqual(["my-remote", "sls+https://user:pass@example.com/db"]);
|
||||
});
|
||||
|
||||
it("parses remote-activate command", () => {
|
||||
process.argv = ["node", "livesync-cli", "./databasePath", "remote-activate", "remote-abc"];
|
||||
const parsed = parseArgs();
|
||||
|
||||
expect(parsed.databasePath).toBe("./databasePath");
|
||||
expect(parsed.command).toBe("remote-activate");
|
||||
expect(parsed.commandArgs).toEqual(["remote-abc"]);
|
||||
});
|
||||
|
||||
it("parses remote-export command", () => {
|
||||
process.argv = ["node", "livesync-cli", "./databasePath", "remote-export", "remote-abc"];
|
||||
const parsed = parseArgs();
|
||||
|
||||
expect(parsed.databasePath).toBe("./databasePath");
|
||||
expect(parsed.command).toBe("remote-export");
|
||||
expect(parsed.commandArgs).toEqual(["remote-abc"]);
|
||||
});
|
||||
|
||||
it("parses remote-set command", () => {
|
||||
process.argv = [
|
||||
"node",
|
||||
"livesync-cli",
|
||||
"./databasePath",
|
||||
"remote-set",
|
||||
"remote-abc",
|
||||
"sls+p2p://room-1?passphrase=abc",
|
||||
];
|
||||
const parsed = parseArgs();
|
||||
|
||||
expect(parsed.databasePath).toBe("./databasePath");
|
||||
expect(parsed.command).toBe("remote-set");
|
||||
expect(parsed.commandArgs).toEqual(["remote-abc", "sls+p2p://room-1?passphrase=abc"]);
|
||||
});
|
||||
|
||||
it("parses --interval flag with valid integer", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault", "--interval", "30"];
|
||||
const parsed = parseArgs();
|
||||
expect(parsed.command).toBe("daemon");
|
||||
expect(parsed.interval).toBe(30);
|
||||
});
|
||||
|
||||
it("parses -i shorthand for --interval", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault", "-i", "10"];
|
||||
const parsed = parseArgs();
|
||||
expect(parsed.interval).toBe(10);
|
||||
});
|
||||
|
||||
it("exits 1 when --interval has no value", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault", "--interval"];
|
||||
const exitMock = mockProcessExit();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(() => parseArgs()).toThrowError("__EXIT__:1");
|
||||
expect(exitMock).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("exits 1 when --interval is not a positive integer", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault", "--interval", "0"];
|
||||
const exitMock = mockProcessExit();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(() => parseArgs()).toThrowError("__EXIT__:1");
|
||||
expect(exitMock).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("exits 1 when --interval is negative", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault", "--interval", "-5"];
|
||||
const exitMock = mockProcessExit();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(() => parseArgs()).toThrowError("__EXIT__:1");
|
||||
});
|
||||
|
||||
it("exits 1 when --interval is not numeric", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault", "--interval", "abc"];
|
||||
const exitMock = mockProcessExit();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(() => parseArgs()).toThrowError("__EXIT__:1");
|
||||
});
|
||||
|
||||
it("parses explicit daemon command", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault", "daemon"];
|
||||
const parsed = parseArgs();
|
||||
expect(parsed.command).toBe("daemon");
|
||||
expect(parsed.databasePath).toBe("./vault");
|
||||
});
|
||||
|
||||
it("defaults to daemon when no command specified", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault"];
|
||||
const parsed = parseArgs();
|
||||
expect(parsed.command).toBe("daemon");
|
||||
});
|
||||
|
||||
it("parses explicit daemon command with --interval", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault", "daemon", "--interval", "30"];
|
||||
const parsed = parseArgs();
|
||||
expect(parsed.command).toBe("daemon");
|
||||
expect(parsed.interval).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,8 +11,11 @@ import type {
|
||||
} from "@lib/managers/adapters";
|
||||
import type { FileEventItemSentinel } from "@lib/managers/StorageEventManager";
|
||||
import type { NodeFile, NodeFolder } from "../adapters/NodeTypes";
|
||||
import type { Stats } from "fs";
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
import { watch as chokidarWatch, type FSWatcher } from "chokidar";
|
||||
import type { IgnoreRules } from "../serviceModules/IgnoreRules";
|
||||
|
||||
/**
|
||||
* CLI-specific type guard adapter
|
||||
@@ -56,22 +59,11 @@ class CLIPersistenceAdapter implements IStorageEventPersistenceAdapter {
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI-specific status adapter (console logging)
|
||||
* CLI-specific status adapter (no-op — daemon uses journald for status)
|
||||
*/
|
||||
class CLIStatusAdapter implements IStorageEventStatusAdapter {
|
||||
private lastUpdate = 0;
|
||||
private updateInterval = 5000; // Update every 5 seconds
|
||||
|
||||
updateStatus(status: { batched: number; processing: number; totalQueued: number }): void {
|
||||
const now = Date.now();
|
||||
if (now - this.lastUpdate > this.updateInterval) {
|
||||
if (status.totalQueued > 0 || status.processing > 0) {
|
||||
// console.log(
|
||||
// `[StorageEventManager] Batched: ${status.batched}, Processing: ${status.processing}, Total Queued: ${status.totalQueued}`
|
||||
// );
|
||||
}
|
||||
this.lastUpdate = now;
|
||||
}
|
||||
updateStatus(_status: { batched: number; processing: number; totalQueued: number }): void {
|
||||
// intentional no-op
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,15 +92,101 @@ class CLIConverterAdapter implements IStorageEventConverterAdapter<NodeFile> {
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI-specific watch adapter (optional file watching with chokidar)
|
||||
* CLI-specific watch adapter using chokidar for real-time filesystem monitoring.
|
||||
*/
|
||||
class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
||||
constructor(private basePath: string) {}
|
||||
private _watcher: FSWatcher | undefined;
|
||||
|
||||
constructor(
|
||||
private basePath: string,
|
||||
private ignoreRules?: IgnoreRules,
|
||||
private watchEnabled: boolean = false
|
||||
) {}
|
||||
|
||||
private _toNodeFile(filePath: string, stats: Stats | undefined): NodeFile {
|
||||
return {
|
||||
path: path.relative(this.basePath, filePath) as FilePath,
|
||||
stat: {
|
||||
ctime: stats?.ctimeMs ?? Date.now(),
|
||||
mtime: stats?.mtimeMs ?? Date.now(),
|
||||
size: stats?.size ?? 0,
|
||||
type: "file",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private _toNodeFolder(dirPath: string): NodeFolder {
|
||||
return {
|
||||
path: path.relative(this.basePath, dirPath) as FilePath,
|
||||
isFolder: true,
|
||||
};
|
||||
}
|
||||
|
||||
async beginWatch(handlers: IStorageEventWatchHandlers): Promise<void> {
|
||||
// File watching is not activated in the CLI.
|
||||
// Because the CLI is designed for push/pull operations, not real-time sync.
|
||||
// console.error("[CLIWatchAdapter] File watching is not enabled in CLI version");
|
||||
if (!this.watchEnabled) return;
|
||||
const baseIgnored: Array<RegExp | string | ((p: string) => boolean)> = [
|
||||
/(^|[/\\])\./,
|
||||
/(^|[/\\])[^/\\]*-livesync-v2([/\\]|$)/,
|
||||
];
|
||||
// Bind rules to a local const before the closure — chokidar v4 requires a
|
||||
// MatchFunction, not glob strings, for custom patterns.
|
||||
const rules = this.ignoreRules;
|
||||
const ignored = rules
|
||||
? [...baseIgnored, (p: string) => rules.shouldIgnore(path.relative(this.basePath, p))]
|
||||
: baseIgnored;
|
||||
|
||||
const watcher = chokidarWatch(this.basePath, {
|
||||
ignored,
|
||||
ignoreInitial: true,
|
||||
persistent: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 500,
|
||||
pollInterval: 100,
|
||||
},
|
||||
});
|
||||
|
||||
watcher.on("add", (filePath, stats) => {
|
||||
const nodeFile = this._toNodeFile(filePath, stats);
|
||||
handlers.onCreate(nodeFile);
|
||||
});
|
||||
|
||||
watcher.on("change", (filePath, stats) => {
|
||||
const nodeFile = this._toNodeFile(filePath, stats);
|
||||
handlers.onChange(nodeFile);
|
||||
});
|
||||
|
||||
watcher.on("unlink", (filePath) => {
|
||||
const nodeFile = this._toNodeFile(filePath, undefined);
|
||||
handlers.onDelete(nodeFile);
|
||||
});
|
||||
|
||||
watcher.on("addDir", (dirPath) => {
|
||||
const nodeFolder = this._toNodeFolder(dirPath);
|
||||
handlers.onCreate(nodeFolder);
|
||||
});
|
||||
|
||||
watcher.on("unlinkDir", (dirPath) => {
|
||||
const nodeFolder = this._toNodeFolder(dirPath);
|
||||
handlers.onDelete(nodeFolder);
|
||||
});
|
||||
|
||||
watcher.on("error", (err) => {
|
||||
console.error("[CLIWatchAdapter] Fatal watcher error — file watching stopped:", err);
|
||||
console.error("[CLIWatchAdapter] Exiting for systemd restart.");
|
||||
void watcher.close();
|
||||
this._watcher = undefined;
|
||||
// Use exit(1) rather than SIGTERM so systemd Restart=on-failure engages.
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => watcher.once("ready", resolve));
|
||||
this._watcher = watcher;
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
if (this._watcher) {
|
||||
return this._watcher.close();
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -123,11 +201,15 @@ export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapte
|
||||
readonly status: CLIStatusAdapter;
|
||||
readonly converter: CLIConverterAdapter;
|
||||
|
||||
constructor(basePath: string) {
|
||||
constructor(basePath: string, ignoreRules?: IgnoreRules, watchEnabled: boolean = false) {
|
||||
this.typeGuard = new CLITypeGuardAdapter();
|
||||
this.persistence = new CLIPersistenceAdapter(basePath);
|
||||
this.watch = new CLIWatchAdapter(basePath);
|
||||
this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled);
|
||||
this.status = new CLIStatusAdapter();
|
||||
this.converter = new CLIConverterAdapter();
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.watch.close();
|
||||
}
|
||||
}
|
||||
|
||||
123
src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts
Normal file
123
src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import type { IStorageEventWatchHandlers } from "@lib/managers/adapters";
|
||||
import type { NodeFile } from "../adapters/NodeTypes";
|
||||
|
||||
// ── chokidar mock ──────────────────────────────────────────────────────────────
|
||||
// Must be hoisted before imports that pull in chokidar.
|
||||
|
||||
const mockWatcher = {
|
||||
on: vi.fn().mockReturnThis(),
|
||||
once: vi.fn((event: string, cb: () => void) => {
|
||||
if (event === "ready") cb();
|
||||
return mockWatcher;
|
||||
}),
|
||||
close: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
vi.mock("chokidar", () => ({
|
||||
watch: vi.fn(() => mockWatcher),
|
||||
}));
|
||||
|
||||
import * as chokidar from "chokidar";
|
||||
import { CLIStorageEventManagerAdapter } from "./CLIStorageEventManagerAdapter";
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeHandlers(): IStorageEventWatchHandlers {
|
||||
return {
|
||||
onCreate: vi.fn(),
|
||||
onChange: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onRename: vi.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CLIStorageEventManagerAdapter", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Restore the default once() behaviour (ready fires synchronously).
|
||||
mockWatcher.once.mockImplementation((event: string, cb: () => void) => {
|
||||
if (event === "ready") cb();
|
||||
return mockWatcher;
|
||||
});
|
||||
});
|
||||
|
||||
it("beginWatch is no-op when watchEnabled=false", async () => {
|
||||
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, false);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
await adapter.watch.beginWatch(handlers);
|
||||
|
||||
expect(chokidar.watch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("beginWatch calls chokidar.watch when watchEnabled=true", async () => {
|
||||
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
await adapter.watch.beginWatch(handlers);
|
||||
|
||||
expect(chokidar.watch).toHaveBeenCalledTimes(1);
|
||||
expect(chokidar.watch).toHaveBeenCalledWith("/base", expect.objectContaining({ ignoreInitial: true }));
|
||||
});
|
||||
|
||||
it("add event produces NodeFile with correct relative path via onCreate", async () => {
|
||||
const basePath = "/vault/base";
|
||||
const adapter = new CLIStorageEventManagerAdapter(basePath, undefined, true);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
await adapter.watch.beginWatch(handlers);
|
||||
|
||||
// Find the callback registered for the "add" event.
|
||||
const addCall = mockWatcher.on.mock.calls.find(([event]) => event === "add");
|
||||
expect(addCall).toBeDefined();
|
||||
const addCallback = addCall![1] as (filePath: string, stats: any) => void;
|
||||
|
||||
const fakeStats = { ctimeMs: 1000, mtimeMs: 2000, size: 42 };
|
||||
addCallback(`${basePath}/subdir/note.md`, fakeStats);
|
||||
|
||||
expect(handlers.onCreate).toHaveBeenCalledTimes(1);
|
||||
const created = (handlers.onCreate as ReturnType<typeof vi.fn>).mock.calls[0][0] as NodeFile;
|
||||
expect(created.path).toBe("subdir/note.md");
|
||||
expect(created.stat?.size).toBe(42);
|
||||
});
|
||||
|
||||
it("close() calls watcher.close()", async () => {
|
||||
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
await adapter.watch.beginWatch(handlers);
|
||||
await adapter.close();
|
||||
|
||||
expect(mockWatcher.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("close() is safe when no watcher was started", async () => {
|
||||
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, false);
|
||||
|
||||
// Should not throw.
|
||||
await expect(adapter.close()).resolves.toBeUndefined();
|
||||
expect(mockWatcher.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("error event triggers process.exit(1)", async () => {
|
||||
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
await adapter.watch.beginWatch(handlers);
|
||||
|
||||
const processExitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as any);
|
||||
|
||||
const errorCall = mockWatcher.on.mock.calls.find(([event]) => event === "error");
|
||||
expect(errorCall).toBeDefined();
|
||||
const errorCallback = errorCall![1] as (err: Error) => void;
|
||||
|
||||
errorCallback(new Error("disk failure"));
|
||||
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { StorageEventManagerBase, type StorageEventManagerBaseDependencies } fro
|
||||
import { CLIStorageEventManagerAdapter } from "./CLIStorageEventManagerAdapter";
|
||||
import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "../../../LiveSyncBaseCore";
|
||||
import type { ServiceContext } from "@lib/services/base/ServiceBase";
|
||||
import type { IgnoreRules } from "../serviceModules/IgnoreRules";
|
||||
// import type { IMinimumLiveSyncCommands } from "@lib/services/base/IService";
|
||||
|
||||
export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEventManagerAdapter> {
|
||||
@@ -10,9 +11,11 @@ export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEv
|
||||
constructor(
|
||||
basePath: string,
|
||||
core: LiveSyncBaseCore<ServiceContext, IMinimumLiveSyncCommands>,
|
||||
dependencies: StorageEventManagerBaseDependencies
|
||||
dependencies: StorageEventManagerBaseDependencies,
|
||||
ignoreRules?: IgnoreRules,
|
||||
watchEnabled?: boolean
|
||||
) {
|
||||
const adapter = new CLIStorageEventManagerAdapter(basePath);
|
||||
const adapter = new CLIStorageEventManagerAdapter(basePath, ignoreRules, watchEnabled);
|
||||
super(adapter, dependencies);
|
||||
this.core = core;
|
||||
}
|
||||
@@ -25,4 +28,11 @@ export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEv
|
||||
// No-op in CLI version
|
||||
// Internal file handling is not needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the file watcher. Call this during graceful shutdown.
|
||||
*/
|
||||
close(): Promise<void> {
|
||||
return this.adapter.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"version": "0.0.0",
|
||||
"description": "Runtime dependencies for Self-hosted LiveSync CLI Docker image",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"commander": "^14.0.3",
|
||||
"werift": "^0.22.9",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ServiceFileAccessCLI } from "./ServiceFileAccessImpl";
|
||||
import { ServiceDatabaseFileAccessCLI } from "./DatabaseFileAccess";
|
||||
import { StorageEventManagerCLI } from "../managers/StorageEventManagerCLI";
|
||||
import type { ServiceModules } from "@lib/interfaces/ServiceModule";
|
||||
import type { IgnoreRules } from "./IgnoreRules";
|
||||
|
||||
/**
|
||||
* Initialize service modules for CLI version
|
||||
@@ -22,7 +23,9 @@ import type { ServiceModules } from "@lib/interfaces/ServiceModule";
|
||||
export function initialiseServiceModulesCLI(
|
||||
basePath: string,
|
||||
core: LiveSyncBaseCore<ServiceContext, any>,
|
||||
services: InjectableServiceHub<ServiceContext>
|
||||
services: InjectableServiceHub<ServiceContext>,
|
||||
ignoreRules?: IgnoreRules,
|
||||
watchEnabled: boolean = false
|
||||
): ServiceModules {
|
||||
const storageAccessManager = new StorageAccessManager();
|
||||
|
||||
@@ -36,12 +39,24 @@ export function initialiseServiceModulesCLI(
|
||||
});
|
||||
|
||||
// CLI-specific storage event manager
|
||||
const storageEventManager = new StorageEventManagerCLI(basePath, core, {
|
||||
fileProcessing: services.fileProcessing,
|
||||
setting: services.setting,
|
||||
vaultService: services.vault,
|
||||
storageAccessManager: storageAccessManager,
|
||||
APIService: services.API,
|
||||
const storageEventManager = new StorageEventManagerCLI(
|
||||
basePath,
|
||||
core,
|
||||
{
|
||||
fileProcessing: services.fileProcessing,
|
||||
setting: services.setting,
|
||||
vaultService: services.vault,
|
||||
storageAccessManager: storageAccessManager,
|
||||
APIService: services.API,
|
||||
},
|
||||
ignoreRules,
|
||||
watchEnabled
|
||||
);
|
||||
|
||||
// Close the file watcher during graceful shutdown so the process can exit cleanly.
|
||||
services.appLifecycle.onUnload.addHandler(async () => {
|
||||
await storageEventManager.close();
|
||||
return true;
|
||||
});
|
||||
|
||||
// Storage access using CLI file system adapter
|
||||
|
||||
131
src/apps/cli/serviceModules/IgnoreRules.ts
Normal file
131
src/apps/cli/serviceModules/IgnoreRules.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
|
||||
import { minimatch } from "minimatch";
|
||||
|
||||
/**
|
||||
* Loads and evaluates ignore rules from `.livesync/ignore` inside the vault.
|
||||
*
|
||||
* File format:
|
||||
* - Lines starting with `#` are comments.
|
||||
* - Blank lines are ignored.
|
||||
* - `import: .gitignore` (exactly) — merges patterns from the vault's `.gitignore`.
|
||||
* - All other lines are minimatch glob patterns relative to the vault root.
|
||||
*
|
||||
* Negation patterns (lines starting with `!`) are not supported. Loading a
|
||||
* ruleset containing them throws an error — use separate include/exclude files
|
||||
* instead.
|
||||
*
|
||||
* Missing files (`.livesync/ignore` or `.gitignore`) are silently skipped.
|
||||
*/
|
||||
export class IgnoreRules {
|
||||
private patterns: string[] = [];
|
||||
|
||||
constructor(private vaultPath: string) {}
|
||||
|
||||
/**
|
||||
* Reads `.livesync/ignore` (and optionally `.gitignore`) and populates the
|
||||
* pattern list. Safe to call multiple times — each call replaces the
|
||||
* previous state. Does not throw if files are absent.
|
||||
*
|
||||
* @throws if any pattern line begins with `!` (negation is unsupported).
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
this.patterns = [];
|
||||
const ignorePath = path.join(this.vaultPath, ".livesync", "ignore");
|
||||
let rawLines: string[];
|
||||
try {
|
||||
const content = await fs.readFile(ignorePath, "utf-8");
|
||||
rawLines = content.split(/\r?\n/);
|
||||
} catch {
|
||||
// File absent or unreadable — treat as empty ruleset.
|
||||
return;
|
||||
}
|
||||
|
||||
for (const line of rawLines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
// NOTE: Only the exact string "import: .gitignore" is recognised.
|
||||
// Any future generalisation of this directive must validate that
|
||||
// the resolved path stays within the vault directory.
|
||||
if (trimmed === "import: .gitignore") {
|
||||
await this._importGitignore();
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith("import:")) {
|
||||
console.error(
|
||||
`[IgnoreRules] Warning: unrecognised directive '${trimmed}' — only 'import: .gitignore' is supported`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
this._addPattern(trimmed);
|
||||
}
|
||||
if (this.patterns.length > 0) {
|
||||
console.error(`[IgnoreRules] Loaded ${this.patterns.length} ignore patterns`);
|
||||
}
|
||||
}
|
||||
|
||||
// Normalises a single gitignore-style pattern:
|
||||
// - Patterns ending with `/` (directory patterns like `build/`) are
|
||||
// converted to `build/**` so they match all files inside that directory.
|
||||
// - Patterns without a `/` are prefixed with `**/` to give them matchBase
|
||||
// semantics (e.g. `*.tmp` → `**/*.tmp`), matching the basename in any
|
||||
// subdirectory as gitignore does.
|
||||
// - Patterns that already contain a `/` (but don't end with one) are
|
||||
// path-specific and used as-is.
|
||||
private _normalisePattern(pattern: string): string {
|
||||
if (pattern.endsWith("/")) {
|
||||
return "**/" + pattern + "**";
|
||||
} else if (!pattern.includes("/")) {
|
||||
return "**/" + pattern;
|
||||
}
|
||||
return pattern;
|
||||
}
|
||||
|
||||
private async _importGitignore(): Promise<void> {
|
||||
const gitignorePath = path.join(this.vaultPath, ".gitignore");
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(gitignorePath, "utf-8");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
this._parseLines(content);
|
||||
}
|
||||
|
||||
private _parseLines(content: string): void {
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
this._addPattern(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
private _addPattern(raw: string): void {
|
||||
if (raw.startsWith("!")) {
|
||||
throw new Error(
|
||||
`[IgnoreRules] Negation pattern '${raw}' is not supported. ` +
|
||||
`Remove it from .livesync/ignore or use a separate include/exclude file.`
|
||||
);
|
||||
}
|
||||
this.patterns.push(this._normalisePattern(raw));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the given vault-relative path matches any loaded
|
||||
* ignore pattern.
|
||||
*
|
||||
* @param relativePath - Path relative to the vault root, using forward
|
||||
* slashes or the OS separator.
|
||||
*/
|
||||
shouldIgnore(relativePath: string): boolean {
|
||||
if (this.patterns.length === 0) {
|
||||
return false;
|
||||
}
|
||||
// Normalise to forward slashes for minimatch.
|
||||
const normalised = relativePath.replace(/\\/g, "/");
|
||||
return this.patterns.some((p) => minimatch(normalised, p, { dot: true }));
|
||||
}
|
||||
}
|
||||
169
src/apps/cli/serviceModules/IgnoreRules.unit.spec.ts
Normal file
169
src/apps/cli/serviceModules/IgnoreRules.unit.spec.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { IgnoreRules } from "./IgnoreRules";
|
||||
|
||||
describe("IgnoreRules", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createVault(): Promise<string> {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-ignorerules-"));
|
||||
tempDirs.push(tempDir);
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
async function writeIgnoreFile(vaultPath: string, content: string): Promise<void> {
|
||||
const ignoreDir = path.join(vaultPath, ".livesync");
|
||||
await fs.mkdir(ignoreDir, { recursive: true });
|
||||
await fs.writeFile(path.join(ignoreDir, "ignore"), content, "utf-8");
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("pattern normalisation", () => {
|
||||
it("adds **/ prefix to basename patterns (no slash)", async () => {
|
||||
const vaultPath = await createVault();
|
||||
await writeIgnoreFile(vaultPath, "*.tmp\n");
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await rules.load();
|
||||
expect(rules.shouldIgnore("notes/scratch.tmp")).toBe(true);
|
||||
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
|
||||
expect(rules.shouldIgnore("deep/nested/file.tmp")).toBe(true);
|
||||
});
|
||||
|
||||
it("appends ** to directory patterns ending with / and prepends **/", async () => {
|
||||
const vaultPath = await createVault();
|
||||
await writeIgnoreFile(vaultPath, "build/\n");
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await rules.load();
|
||||
expect(rules.shouldIgnore("build/output.js")).toBe(true);
|
||||
expect(rules.shouldIgnore("build/nested/file.js")).toBe(true);
|
||||
expect(rules.shouldIgnore("subproject/build/output.js")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves patterns containing / as-is", async () => {
|
||||
const vaultPath = await createVault();
|
||||
await writeIgnoreFile(vaultPath, "docs/private.md\n");
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await rules.load();
|
||||
expect(rules.shouldIgnore("docs/private.md")).toBe(true);
|
||||
expect(rules.shouldIgnore("other/docs/private.md")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldIgnore", () => {
|
||||
it("matches **/*.tmp against notes/scratch.tmp", async () => {
|
||||
const vaultPath = await createVault();
|
||||
await writeIgnoreFile(vaultPath, "*.tmp\n");
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await rules.load();
|
||||
expect(rules.shouldIgnore("notes/scratch.tmp")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match notes/readme.md against **/*.tmp", async () => {
|
||||
const vaultPath = await createVault();
|
||||
await writeIgnoreFile(vaultPath, "*.tmp\n");
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await rules.load();
|
||||
expect(rules.shouldIgnore("notes/readme.md")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when no patterns are loaded", async () => {
|
||||
const vaultPath = await createVault();
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
// No load() call — patterns are empty
|
||||
expect(rules.shouldIgnore("anything.md")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("negation patterns", () => {
|
||||
it("throws when a negation pattern is encountered", async () => {
|
||||
const vaultPath = await createVault();
|
||||
await writeIgnoreFile(vaultPath, "*.tmp\n!important.tmp\n");
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await expect(rules.load()).rejects.toThrow(/Negation pattern/);
|
||||
});
|
||||
|
||||
it("throws when a .gitignore imported via directive contains negation", async () => {
|
||||
const vaultPath = await createVault();
|
||||
await writeIgnoreFile(vaultPath, "import: .gitignore\n");
|
||||
await fs.writeFile(path.join(vaultPath, ".gitignore"), "*.log\n!keep.log\n", "utf-8");
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await expect(rules.load()).rejects.toThrow(/Negation pattern/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unrecognised import: directives", () => {
|
||||
it("warns and skips unrecognised import: forms (does not add as literal pattern)", async () => {
|
||||
const vaultPath = await createVault();
|
||||
// Typo: "import:.gitignore" instead of "import: .gitignore"
|
||||
await writeIgnoreFile(vaultPath, "*.tmp\nimport:.gitignore\n");
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await rules.load();
|
||||
// *.tmp still loaded; import:.gitignore is skipped (not treated as a literal pattern)
|
||||
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
|
||||
expect(rules.shouldIgnore("import:.gitignore")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("load() with missing file", () => {
|
||||
it("returns without error when .livesync/ignore is absent", async () => {
|
||||
const vaultPath = await createVault();
|
||||
// No ignore file created
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await expect(rules.load()).resolves.toBeUndefined();
|
||||
expect(rules.shouldIgnore("anything.md")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("load() with comments and blank lines", () => {
|
||||
it("skips # comment lines and blank lines", async () => {
|
||||
const vaultPath = await createVault();
|
||||
await writeIgnoreFile(vaultPath, "# This is a comment\n\n \n*.tmp\n# another comment\nbuild/\n");
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await rules.load();
|
||||
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
|
||||
expect(rules.shouldIgnore("build/output.js")).toBe(true);
|
||||
expect(rules.shouldIgnore("readme.md")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("import: .gitignore directive", () => {
|
||||
it("reads and normalises patterns from .gitignore", async () => {
|
||||
const vaultPath = await createVault();
|
||||
await writeIgnoreFile(vaultPath, "import: .gitignore\n");
|
||||
await fs.writeFile(path.join(vaultPath, ".gitignore"), "*.log\nnode_modules/\n", "utf-8");
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await rules.load();
|
||||
expect(rules.shouldIgnore("app.log")).toBe(true);
|
||||
expect(rules.shouldIgnore("node_modules/package.json")).toBe(true);
|
||||
expect(rules.shouldIgnore("src/node_modules/package.json")).toBe(true);
|
||||
expect(rules.shouldIgnore("src/index.ts")).toBe(false);
|
||||
});
|
||||
|
||||
it("merges .gitignore patterns with other patterns", async () => {
|
||||
const vaultPath = await createVault();
|
||||
await writeIgnoreFile(vaultPath, "*.tmp\nimport: .gitignore\n");
|
||||
await fs.writeFile(path.join(vaultPath, ".gitignore"), "*.log\n", "utf-8");
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await rules.load();
|
||||
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
|
||||
expect(rules.shouldIgnore("error.log")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("import: .gitignore with missing .gitignore", () => {
|
||||
it("does not throw when .gitignore is absent", async () => {
|
||||
const vaultPath = await createVault();
|
||||
await writeIgnoreFile(vaultPath, "*.tmp\nimport: .gitignore\n");
|
||||
// No .gitignore created
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await expect(rules.load()).resolves.toBeUndefined();
|
||||
// The *.tmp pattern from the ignore file still works
|
||||
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
166
src/apps/cli/test/test-daemon-linux.sh
Executable file
166
src/apps/cli/test/test-daemon-linux.sh
Executable file
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
# Test: daemon-related ignore rules behaviour
|
||||
#
|
||||
# Tests that are runnable without a long-running daemon process are exercised
|
||||
# here using the `mirror` command, which calls the same `isTargetFile` handler
|
||||
# stack that the daemon uses.
|
||||
#
|
||||
# Covered cases:
|
||||
# 1. .livesync/ignore with *.tmp pattern → ignored file is NOT synced to DB
|
||||
# 2. .livesync/ignore missing → no error, normal sync continues
|
||||
# 3. import: .gitignore directive → patterns from .gitignore are merged
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CLI_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$CLI_DIR"
|
||||
source "$SCRIPT_DIR/test-helpers.sh"
|
||||
display_test_info
|
||||
|
||||
RUN_BUILD="${RUN_BUILD:-1}"
|
||||
cli_test_init_cli_cmd
|
||||
|
||||
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/livesync-cli-daemon-test.XXXXXX")"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
SETTINGS_FILE="$WORK_DIR/data.json"
|
||||
VAULT_DIR="$WORK_DIR/vault"
|
||||
mkdir -p "$VAULT_DIR/notes"
|
||||
|
||||
if [[ "$RUN_BUILD" == "1" ]]; then
|
||||
echo "[INFO] building CLI..."
|
||||
npm run build
|
||||
fi
|
||||
|
||||
echo "[INFO] generating settings -> $SETTINGS_FILE"
|
||||
cli_test_init_settings_file "$SETTINGS_FILE"
|
||||
cli_test_mark_settings_configured "$SETTINGS_FILE"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_pass() { echo "[PASS] $1"; PASS=$((PASS + 1)); }
|
||||
assert_fail() { echo "[FAIL] $1" >&2; FAIL=$((FAIL + 1)); }
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Case 1: .livesync/ignore with *.tmp → matched file should NOT appear in DB
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "=== Case 1: .livesync/ignore *.tmp → ignored file not synced to DB ==="
|
||||
|
||||
mkdir -p "$VAULT_DIR/.livesync"
|
||||
printf '*.tmp\n' > "$VAULT_DIR/.livesync/ignore"
|
||||
|
||||
# Also write a normal file so we can confirm mirror ran at all.
|
||||
printf 'normal content\n' > "$VAULT_DIR/notes/normal.md"
|
||||
# Write the file that should be ignored.
|
||||
printf 'tmp content\n' > "$VAULT_DIR/notes/scratch.tmp"
|
||||
|
||||
run_cli "$VAULT_DIR" --settings "$SETTINGS_FILE" mirror
|
||||
|
||||
# The normal file should be in the DB.
|
||||
RESULT_NORMAL="$WORK_DIR/case1-normal.txt"
|
||||
if run_cli "$VAULT_DIR" --settings "$SETTINGS_FILE" pull notes/normal.md "$RESULT_NORMAL" 2>/dev/null; then
|
||||
if cmp -s "$VAULT_DIR/notes/normal.md" "$RESULT_NORMAL"; then
|
||||
assert_pass "normal.md was synced to DB"
|
||||
else
|
||||
assert_fail "normal.md content mismatch after mirror"
|
||||
fi
|
||||
else
|
||||
assert_fail "normal.md was not found in DB after mirror"
|
||||
fi
|
||||
|
||||
# The .tmp file should NOT be in the DB.
|
||||
DB_LIST="$WORK_DIR/case1-ls.txt"
|
||||
run_cli "$VAULT_DIR" --settings "$SETTINGS_FILE" ls > "$DB_LIST"
|
||||
if grep -q "scratch.tmp" "$DB_LIST"; then
|
||||
assert_fail "scratch.tmp (ignored) was unexpectedly synced to DB"
|
||||
echo "--- DB listing ---" >&2; cat "$DB_LIST" >&2
|
||||
else
|
||||
assert_pass "scratch.tmp (*.tmp pattern) was NOT synced to DB"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Case 2: .livesync/ignore absent → no error, normal sync continues
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "=== Case 2: .livesync/ignore absent → no error, sync continues ==="
|
||||
|
||||
VAULT_DIR2="$WORK_DIR/vault2"
|
||||
mkdir -p "$VAULT_DIR2/notes"
|
||||
SETTINGS_FILE2="$WORK_DIR/data2.json"
|
||||
cli_test_init_settings_file "$SETTINGS_FILE2"
|
||||
cli_test_mark_settings_configured "$SETTINGS_FILE2"
|
||||
|
||||
# No .livesync directory at all.
|
||||
printf 'hello\n' > "$VAULT_DIR2/notes/hello.md"
|
||||
|
||||
# mirror should succeed without error.
|
||||
set +e
|
||||
MIRROR_OUTPUT="$WORK_DIR/case2-mirror.txt"
|
||||
run_cli "$VAULT_DIR2" --settings "$SETTINGS_FILE2" mirror >"$MIRROR_OUTPUT" 2>&1
|
||||
MIRROR_EXIT=$?
|
||||
set -e
|
||||
|
||||
if [[ "$MIRROR_EXIT" -ne 0 ]]; then
|
||||
assert_fail "mirror exited non-zero ($MIRROR_EXIT) when .livesync/ignore is absent"
|
||||
cat "$MIRROR_OUTPUT" >&2
|
||||
else
|
||||
assert_pass "mirror succeeded when .livesync/ignore is absent"
|
||||
fi
|
||||
|
||||
# The normal file should have been synced.
|
||||
RESULT_HELLO="$WORK_DIR/case2-hello.txt"
|
||||
if run_cli "$VAULT_DIR2" --settings "$SETTINGS_FILE2" pull notes/hello.md "$RESULT_HELLO" 2>/dev/null; then
|
||||
assert_pass "file synced normally when .livesync/ignore is absent"
|
||||
else
|
||||
assert_fail "file was not synced when .livesync/ignore is absent"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Case 3: import: .gitignore merges patterns
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "=== Case 3: import: .gitignore directive merges patterns ==="
|
||||
|
||||
VAULT_DIR3="$WORK_DIR/vault3"
|
||||
mkdir -p "$VAULT_DIR3/notes"
|
||||
SETTINGS_FILE3="$WORK_DIR/data3.json"
|
||||
cli_test_init_settings_file "$SETTINGS_FILE3"
|
||||
cli_test_mark_settings_configured "$SETTINGS_FILE3"
|
||||
|
||||
mkdir -p "$VAULT_DIR3/.livesync"
|
||||
printf 'import: .gitignore\n' > "$VAULT_DIR3/.livesync/ignore"
|
||||
printf '# gitignore comment\n*.log\nbuild/\n' > "$VAULT_DIR3/.gitignore"
|
||||
|
||||
printf 'regular note\n' > "$VAULT_DIR3/notes/regular.md"
|
||||
printf 'log content\n' > "$VAULT_DIR3/notes/debug.log"
|
||||
|
||||
run_cli "$VAULT_DIR3" --settings "$SETTINGS_FILE3" mirror
|
||||
|
||||
DB_LIST3="$WORK_DIR/case3-ls.txt"
|
||||
run_cli "$VAULT_DIR3" --settings "$SETTINGS_FILE3" ls > "$DB_LIST3"
|
||||
|
||||
if grep -q "debug.log" "$DB_LIST3"; then
|
||||
assert_fail "debug.log (ignored via .gitignore import) was unexpectedly synced to DB"
|
||||
echo "--- DB listing ---" >&2; cat "$DB_LIST3" >&2
|
||||
else
|
||||
assert_pass "debug.log (*.log from imported .gitignore) was NOT synced to DB"
|
||||
fi
|
||||
|
||||
# regular.md should still be present.
|
||||
if grep -q "regular.md" "$DB_LIST3"; then
|
||||
assert_pass "regular.md was synced normally alongside .gitignore import rules"
|
||||
else
|
||||
assert_fail "regular.md was NOT synced — .gitignore import may have been too broad"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Summary
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Results: PASS=$PASS FAIL=$FAIL"
|
||||
if [[ "$FAIL" -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -29,7 +29,8 @@ export async function runScenario(remoteType: RemoteType, encrypt: boolean): Pro
|
||||
const dbPrefix = remoteType === "COUCHDB" ? requireEnv("COUCHDB_DBNAME", "dbname") : "";
|
||||
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 minioSecretKey = remoteType === "MINIO" ? requireEnv("MINIO_SECRET_KEY", "secretKey") : "";
|
||||
const minioBucketBase = remoteType === "MINIO" ? requireEnv("MINIO_BUCKET_NAME", "bucketName") : "";
|
||||
|
||||
@@ -11,11 +11,55 @@ const defaultExternal = [
|
||||
"crypto",
|
||||
"pouchdb-adapter-leveldb",
|
||||
"commander",
|
||||
"chokidar",
|
||||
"punycode",
|
||||
"werift",
|
||||
];
|
||||
// Polyfill FileReader at the very top of the CJS bundle. octagonal-wheels uses
|
||||
// FileReader for base64 conversion when Uint8Array.toBase64 (TC39 proposal) is
|
||||
// unavailable. Node.js has neither, so we inject a minimal FileReader shim before
|
||||
// any module-scope code evaluates.
|
||||
const fileReaderPolyfillBanner = `
|
||||
if (typeof globalThis.FileReader === "undefined") {
|
||||
globalThis.FileReader = class FileReader {
|
||||
constructor() { this.result = null; this.onload = null; this.onerror = null; }
|
||||
readAsDataURL(blob) {
|
||||
blob.arrayBuffer().then((buf) => {
|
||||
var b64 = require("buffer").Buffer.from(buf).toString("base64");
|
||||
this.result = "data:" + (blob.type || "application/octet-stream") + ";base64," + b64;
|
||||
if (this.onload) this.onload({ target: this });
|
||||
}).catch((err) => { if (this.onerror) this.onerror({ target: this, error: err }); });
|
||||
}
|
||||
readAsArrayBuffer() { throw new Error("FileReader.readAsArrayBuffer is not implemented in this polyfill"); }
|
||||
readAsBinaryString() { throw new Error("FileReader.readAsBinaryString is not implemented in this polyfill"); }
|
||||
readAsText() { throw new Error("FileReader.readAsText is not implemented in this polyfill"); }
|
||||
abort() { throw new Error("FileReader.abort is not implemented in this polyfill"); }
|
||||
};
|
||||
}
|
||||
`;
|
||||
|
||||
function injectBanner(): import("vite").Plugin {
|
||||
return {
|
||||
name: "inject-banner",
|
||||
generateBundle(_options, bundle) {
|
||||
for (const chunk of Object.values(bundle)) {
|
||||
if (chunk.type === "chunk" && chunk.fileName.startsWith("entrypoint")) {
|
||||
// Insert after the shebang line if present, otherwise at the top.
|
||||
if (chunk.code.startsWith("#!")) {
|
||||
const newline = chunk.code.indexOf("\n");
|
||||
chunk.code =
|
||||
chunk.code.slice(0, newline + 1) + fileReaderPolyfillBanner + chunk.code.slice(newline + 1);
|
||||
} else {
|
||||
chunk.code = fileReaderPolyfillBanner + chunk.code;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
plugins: [svelte(), injectBanner()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@lib/worker/bgWorker.ts": "../../lib/src/worker/bgWorker.mock.ts",
|
||||
|
||||
@@ -41,7 +41,7 @@ async function renderHistoryList(): Promise<VaultHistoryItem[]> {
|
||||
|
||||
const [items, lastUsedId] = await Promise.all([historyStore.getVaultHistory(), historyStore.getLastUsedVaultId()]);
|
||||
|
||||
listEl.innerHTML = "";
|
||||
listEl.replaceChildren();
|
||||
emptyEl.classList.toggle("is-hidden", items.length > 0);
|
||||
|
||||
for (const item of items) {
|
||||
|
||||
142
src/common/reportTool.ts
Normal file
142
src/common/reportTool.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { REMOTE_COUCHDB, REMOTE_MINIO } from "@lib/common/models/setting.const";
|
||||
import type { ObsidianLiveSyncSettings } from "@lib/common/models/setting.type";
|
||||
import { generateCredentialObject } from "@lib/replication/httplib";
|
||||
import { parseHeaderValues } from "@lib/common/utils";
|
||||
import { requestToCouchDBWithCredentials } from "./utils";
|
||||
import { LOG_LEVEL_VERBOSE, Logger } from "@lib/common/logger";
|
||||
import { DEFAULT_SETTINGS } from "@lib/common/models/setting.const.defaults";
|
||||
import { isCloudantURI } from "@lib/pouchdb/utils_couchdb";
|
||||
import { compatGlobal } from "@lib/common/coreEnvFunctions";
|
||||
import { manifestVersion, packageVersion } from "@lib/common/coreEnvVars";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
function redactObject(obj: Record<string, any>, dotted: string, redactedValue = "REDACTED") {
|
||||
const keys = dotted.split(".");
|
||||
let current = obj;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i];
|
||||
if (!(key in current)) {
|
||||
current[key] = {} as Record<string, any>;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
current = current[key];
|
||||
}
|
||||
const lastKey = keys[keys.length - 1];
|
||||
if (lastKey in current) {
|
||||
current[lastKey] = redactedValue;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
export async function generateReport(settings: ObsidianLiveSyncSettings, core: LiveSyncBaseCore) {
|
||||
let responseConfig: Record<string, any> = {};
|
||||
const REDACTED = "𝑅𝐸𝐷𝐴𝐶𝑇𝐸𝐷";
|
||||
if (settings.remoteType == REMOTE_COUCHDB) {
|
||||
try {
|
||||
const credential = generateCredentialObject(settings);
|
||||
const customHeaders = parseHeaderValues(settings.couchDB_CustomHeaders);
|
||||
const r = await requestToCouchDBWithCredentials(
|
||||
settings.couchDB_URI,
|
||||
credential,
|
||||
window.origin,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
customHeaders
|
||||
);
|
||||
responseConfig = r.json as Record<string, any>;
|
||||
redactObject(responseConfig, "couch_httpd_auth.secret");
|
||||
redactObject(responseConfig, "couch_httpd_auth.authentication_db");
|
||||
redactObject(responseConfig, "couch_httpd_auth.authentication_redirect");
|
||||
redactObject(responseConfig, "couchdb.uuid");
|
||||
redactObject(responseConfig, "admins");
|
||||
redactObject(responseConfig, "users");
|
||||
redactObject(responseConfig, "chttpd_auth.secret");
|
||||
delete responseConfig["jwt_keys"];
|
||||
} catch (ex) {
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
responseConfig = {
|
||||
error: "Requesting information from the remote CouchDB has failed. If you are using IBM Cloudant, this is normal behaviour.",
|
||||
};
|
||||
}
|
||||
} else if (settings.remoteType == REMOTE_MINIO) {
|
||||
responseConfig = { error: "Object Storage Synchronisation" };
|
||||
//
|
||||
}
|
||||
const defaultKeys = Object.keys(DEFAULT_SETTINGS) as (keyof ObsidianLiveSyncSettings)[];
|
||||
const pluginConfig = JSON.parse(JSON.stringify(settings)) as ObsidianLiveSyncSettings;
|
||||
const pluginKeys = Object.keys(pluginConfig);
|
||||
for (const key of pluginKeys) {
|
||||
if (defaultKeys.includes(key as keyof ObsidianLiveSyncSettings)) continue;
|
||||
delete pluginConfig[key as keyof ObsidianLiveSyncSettings];
|
||||
}
|
||||
|
||||
pluginConfig.couchDB_DBNAME = REDACTED;
|
||||
pluginConfig.couchDB_PASSWORD = REDACTED;
|
||||
const scheme = pluginConfig.couchDB_URI.startsWith("http:")
|
||||
? "(HTTP)"
|
||||
: pluginConfig.couchDB_URI.startsWith("https:")
|
||||
? "(HTTPS)"
|
||||
: "";
|
||||
pluginConfig.couchDB_URI = isCloudantURI(pluginConfig.couchDB_URI) ? "cloudant" : `self-hosted${scheme}`;
|
||||
pluginConfig.couchDB_USER = REDACTED;
|
||||
pluginConfig.passphrase = REDACTED;
|
||||
pluginConfig.encryptedPassphrase = REDACTED;
|
||||
pluginConfig.encryptedCouchDBConnection = REDACTED;
|
||||
pluginConfig.accessKey = REDACTED;
|
||||
pluginConfig.secretKey = REDACTED;
|
||||
const redact = (source: string) => `${REDACTED}(${source.length} letters)`;
|
||||
const toSchemeOnly = (uri: string) => {
|
||||
try {
|
||||
return `${new URL(uri).protocol}//`;
|
||||
} catch {
|
||||
const matched = uri.match(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//);
|
||||
return matched?.[0] ?? REDACTED;
|
||||
}
|
||||
};
|
||||
pluginConfig.remoteConfigurations = Object.fromEntries(
|
||||
Object.entries(pluginConfig.remoteConfigurations || {}).map(([id, config]) => [
|
||||
id,
|
||||
{
|
||||
...config,
|
||||
uri: toSchemeOnly(config.uri),
|
||||
},
|
||||
])
|
||||
);
|
||||
pluginConfig.region = redact(pluginConfig.region);
|
||||
pluginConfig.bucket = redact(pluginConfig.bucket);
|
||||
pluginConfig.pluginSyncExtendedSetting = {};
|
||||
pluginConfig.P2P_AppID = redact(pluginConfig.P2P_AppID);
|
||||
pluginConfig.P2P_passphrase = redact(pluginConfig.P2P_passphrase);
|
||||
pluginConfig.P2P_roomID = redact(pluginConfig.P2P_roomID);
|
||||
pluginConfig.P2P_relays = redact(pluginConfig.P2P_relays);
|
||||
pluginConfig.jwtKey = redact(pluginConfig.jwtKey);
|
||||
pluginConfig.jwtSub = redact(pluginConfig.jwtSub);
|
||||
pluginConfig.jwtKid = redact(pluginConfig.jwtKid);
|
||||
pluginConfig.bucketCustomHeaders = redact(pluginConfig.bucketCustomHeaders);
|
||||
pluginConfig.couchDB_CustomHeaders = redact(pluginConfig.couchDB_CustomHeaders);
|
||||
pluginConfig.P2P_turnCredential = redact(pluginConfig.P2P_turnCredential);
|
||||
pluginConfig.P2P_turnUsername = redact(pluginConfig.P2P_turnUsername);
|
||||
pluginConfig.P2P_turnServers = `(${pluginConfig.P2P_turnServers.split(",").length} servers configured)`;
|
||||
const endpoint = pluginConfig.endpoint;
|
||||
if (endpoint == "") {
|
||||
pluginConfig.endpoint = "Not configured or AWS";
|
||||
} else {
|
||||
const endpointScheme = pluginConfig.endpoint.startsWith("http:")
|
||||
? "(HTTP)"
|
||||
: pluginConfig.endpoint.startsWith("https:")
|
||||
? "(HTTPS)"
|
||||
: "";
|
||||
pluginConfig.endpoint = `${endpoint.indexOf(".r2.cloudflarestorage.") !== -1 ? "R2" : "self-hosted?"}(${endpointScheme})`;
|
||||
}
|
||||
const obsidianInfo = {
|
||||
navigator: compatGlobal.navigator.userAgent,
|
||||
fileSystem: core.services.vault.isStorageInsensitive() ? "insensitive" : "sensitive",
|
||||
};
|
||||
const result = {
|
||||
obsidianInfo,
|
||||
responseConfig,
|
||||
pluginConfig,
|
||||
manifestVersion,
|
||||
packageVersion,
|
||||
};
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { App, Modal } from "@/deps.ts";
|
||||
import P2POpenReplicationPane from "./P2POpenReplicationPane.svelte";
|
||||
import { mount, unmount } from "svelte";
|
||||
import type { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
|
||||
export type P2POpenReplicationModalCallback = {
|
||||
onSync: (peerId: string) => Promise<void>;
|
||||
onSyncAndClose: (peerId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export class P2POpenReplicationModal extends Modal {
|
||||
liveSyncReplicator: LiveSyncTrysteroReplicator;
|
||||
callback?: P2POpenReplicationModalCallback;
|
||||
component?: ReturnType<typeof mount>;
|
||||
showResult: boolean;
|
||||
title: string;
|
||||
onClosed?: () => void;
|
||||
rebuildMode: boolean;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
liveSyncReplicator: LiveSyncTrysteroReplicator,
|
||||
callback?: P2POpenReplicationModalCallback,
|
||||
showResult: boolean = false,
|
||||
title: string = "P2P Replication",
|
||||
onClosed?: () => void,
|
||||
rebuildMode: boolean = false
|
||||
) {
|
||||
super(app);
|
||||
this.liveSyncReplicator = liveSyncReplicator;
|
||||
this.callback = callback;
|
||||
this.showResult = showResult;
|
||||
this.title = title;
|
||||
this.onClosed = onClosed;
|
||||
this.rebuildMode = rebuildMode;
|
||||
}
|
||||
|
||||
async onSync(peerId: string) {
|
||||
if (this.callback?.onSync) {
|
||||
await this.callback.onSync(peerId);
|
||||
}
|
||||
}
|
||||
|
||||
async onSyncAndClose(peerId: string) {
|
||||
if (this.callback?.onSyncAndClose) {
|
||||
await this.callback.onSyncAndClose(peerId);
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
|
||||
override onOpen() {
|
||||
const { contentEl } = this;
|
||||
this.titleEl.setText(this.title);
|
||||
contentEl.empty();
|
||||
|
||||
if (this.component === undefined) {
|
||||
this.component = mount(P2POpenReplicationPane, {
|
||||
target: contentEl,
|
||||
props: {
|
||||
liveSyncReplicator: this.liveSyncReplicator,
|
||||
onSync: (peerId: string) => this.onSync(peerId),
|
||||
onSyncAndClose: (peerId: string) => this.onSyncAndClose(peerId),
|
||||
onClose: () => this.close(),
|
||||
showResult: this.showResult,
|
||||
rebuildMode: this.rebuildMode,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
override onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
if (this.component !== undefined) {
|
||||
void unmount(this.component);
|
||||
this.component = undefined;
|
||||
}
|
||||
this.onClosed?.();
|
||||
}
|
||||
}
|
||||
313
src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte
Normal file
313
src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte
Normal file
@@ -0,0 +1,313 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { eventHub } from "@/common/events";
|
||||
import {
|
||||
EVENT_SERVER_STATUS,
|
||||
EVENT_REQUEST_STATUS,
|
||||
type P2PServerInfo,
|
||||
} from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
// import type { TrysteroReplicator } from "@lib/replication/trystero/TrysteroReplicator";
|
||||
import { LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "@lib/common/types";
|
||||
import { Logger } from "@lib/common/logger";
|
||||
import type { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import { delay, fireAndForget } from "octagonal-wheels/promises";
|
||||
import P2PServerStatusCard from "./P2PServerStatusCard.svelte";
|
||||
|
||||
interface Props {
|
||||
liveSyncReplicator: LiveSyncTrysteroReplicator;
|
||||
onSync: (_peerId: string) => Promise<void>;
|
||||
onSyncAndClose: (_peerId: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
showResult: boolean;
|
||||
rebuildMode?: boolean;
|
||||
}
|
||||
|
||||
let { onSync, onSyncAndClose, onClose, showResult, liveSyncReplicator, rebuildMode = false }: Props = $props();
|
||||
|
||||
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
|
||||
let syncingPeerId = $state<string | null>(null);
|
||||
|
||||
const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||
async function requestServerStatus() {
|
||||
await liveSyncReplicator.requestStatus();
|
||||
eventHub.emitEvent(EVENT_REQUEST_STATUS);
|
||||
}
|
||||
onMount(() => {
|
||||
// ServerStatus
|
||||
const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
|
||||
serverInfo = status;
|
||||
});
|
||||
fireAndForget(async () => {
|
||||
await delay(100);
|
||||
await requestServerStatus();
|
||||
});
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
async function handleSync(peerId: string) {
|
||||
try {
|
||||
syncingPeerId = peerId;
|
||||
Logger(`Starting sync with ${peerId}`, logLevel);
|
||||
await onSync(peerId);
|
||||
Logger(`Sync completed with ${peerId}`, logLevel);
|
||||
} catch (e) {
|
||||
Logger(`Error during sync: ${e instanceof Error ? e.message : String(e)}`, logLevel);
|
||||
} finally {
|
||||
syncingPeerId = null;
|
||||
}
|
||||
}
|
||||
async function handleSyncThenClose(peerId: string) {
|
||||
try {
|
||||
syncingPeerId = peerId;
|
||||
Logger(`Starting sync with ${peerId}`, logLevel);
|
||||
await onSyncAndClose(peerId);
|
||||
Logger(`Sync completed with ${peerId}`, logLevel);
|
||||
} catch (e) {
|
||||
Logger(`Error during sync: ${e instanceof Error ? e.message : String(e)}`, logLevel);
|
||||
} finally {
|
||||
syncingPeerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncAndClose(peerId: string) {
|
||||
fireAndForget(async () => {
|
||||
try {
|
||||
Logger(`Starting sync with ${peerId}`, logLevel);
|
||||
await onSync(peerId);
|
||||
Logger(`Sync completed with ${peerId}`, logLevel);
|
||||
} catch (e) {
|
||||
Logger(`Error during sync: ${e instanceof Error ? e.message : String(e)}`, logLevel);
|
||||
}
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
async function disconnect() {
|
||||
try {
|
||||
await liveSyncReplicator.close();
|
||||
Logger("Signalling connection closed.", logLevel);
|
||||
} catch (e) {
|
||||
Logger(`Failed to close signalling connection: ${e instanceof Error ? e.message : String(e)}`, logLevel);
|
||||
}
|
||||
}
|
||||
async function onCloseAndDisconnect() {
|
||||
await disconnect();
|
||||
onClose();
|
||||
}
|
||||
|
||||
function getAcceptanceStatus(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||
if (peer.isTemporaryAccepted === true) return "ACCEPTED (in session)";
|
||||
if (peer.isAccepted === true) return "ACCEPTED";
|
||||
if (peer.isTemporaryAccepted === false) return "DENIED (in session)";
|
||||
if (peer.isAccepted === false) return "DENIED";
|
||||
return "NEW";
|
||||
}
|
||||
|
||||
function getAcceptanceStatusClass(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||
if (peer.isTemporaryAccepted === true || peer.isAccepted === true) return "accepted";
|
||||
if (peer.isTemporaryAccepted === false || peer.isAccepted === false) return "denied";
|
||||
return "unknown";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p2p-container">
|
||||
<P2PServerStatusCard {liveSyncReplicator} showBroadcastToggle={false} />
|
||||
|
||||
<div class="peers-section">
|
||||
<h3>Available Peers</h3>
|
||||
{#if serverInfo && serverInfo.knownAdvertisements.length > 0}
|
||||
<div class="peers-list">
|
||||
{#each serverInfo.knownAdvertisements as peer (peer.peerId)}
|
||||
<div class="peer-item">
|
||||
<div class="peer-info">
|
||||
<div class="peer-name">{peer.name}</div>
|
||||
<div class="peer-meta">
|
||||
<span class="badge">{peer.platform}</span>
|
||||
<span class="peer-id-mini" title={peer.peerId}>
|
||||
{peer.peerId.slice(0, 8)}
|
||||
</span>
|
||||
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
|
||||
{getAcceptanceStatus(peer)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="peer-actions">
|
||||
{#if !rebuildMode}
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
disabled={syncingPeerId !== null}
|
||||
onclick={() => handleSync(peer.peerId)}
|
||||
>
|
||||
{syncingPeerId === peer.peerId ? "Syncing..." : "Sync"}
|
||||
</button>
|
||||
<button
|
||||
class="btn {rebuildMode ? 'btn-primary' : 'btn-secondary'}"
|
||||
disabled={syncingPeerId !== null}
|
||||
onclick={() => handleSyncAndClose(peer.peerId)}
|
||||
>
|
||||
{syncingPeerId === peer.peerId ? "Syncing..." : "Start Sync & Close"}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn {rebuildMode ? 'btn-primary' : 'btn-secondary'}"
|
||||
disabled={syncingPeerId !== null}
|
||||
onclick={() => handleSyncThenClose(peer.peerId)}
|
||||
>
|
||||
{syncingPeerId === peer.peerId ? "Syncing..." : "Sync"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if serverInfo}
|
||||
<p class="no-peers">No devices available. Waiting for other devices to connect...</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
{#if rebuildMode}
|
||||
<button class="btn btn-cancel" onclick={onClose} disabled={syncingPeerId !== null}>Skip and close</button>
|
||||
{:else}
|
||||
<button class="btn btn-cancel" onclick={onClose}>Close</button>
|
||||
<button class="btn btn-cancel" onclick={onCloseAndDisconnect}>Close & Disconnect</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.p2p-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.peers-section {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.peers-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.peer-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--background-secondary);
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
|
||||
.peer-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.peer-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.peer-meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background-color: var(--background-tertiary);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-chip.accepted {
|
||||
background-color: var(--background-modifier-success);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.status-chip.denied {
|
||||
background-color: var(--background-modifier-error);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.status-chip.unknown {
|
||||
background-color: var(--background-modifier-border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.peer-id-mini {
|
||||
font-family: monospace;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.peer-actions {
|
||||
flex-wrap: wrap;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.3rem;
|
||||
background-color: var(--interactive-normal);
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background-color: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--background-tertiary);
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.no-peers {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid var(--divider-color);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
131
src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts
Normal file
131
src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { App } from "@/deps.ts";
|
||||
import { Logger } from "@lib/common/logger";
|
||||
import { LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "@lib/common/types";
|
||||
import type { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import { P2POpenReplicationModal } from "./P2POpenReplicationModal";
|
||||
|
||||
/**
|
||||
* Creates an openReplicationUI factory for Obsidian environments.
|
||||
* Returns a per-replicator closure that opens the P2P Replication modal
|
||||
* and performs bidirectional sync (pull then push on success).
|
||||
*
|
||||
* Usage:
|
||||
* const factory = createOpenReplicationUI(app);
|
||||
* useP2PReplicatorFeature(core, factory);
|
||||
*/
|
||||
export function createOpenReplicationUI(
|
||||
app: App
|
||||
): (replicator: LiveSyncTrysteroReplicator) => (showResult: boolean) => Promise<boolean | void> {
|
||||
return (replicator: LiveSyncTrysteroReplicator) =>
|
||||
(showResult: boolean): Promise<boolean | void> => {
|
||||
const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||
return new Promise<boolean | void>((resolve) => {
|
||||
const modal = new P2POpenReplicationModal(
|
||||
app,
|
||||
replicator,
|
||||
{
|
||||
onSync: async (peerId: string) => {
|
||||
try {
|
||||
// pull (replicateFrom) first; push only on success
|
||||
const pullResult = await replicator.replicateFrom(peerId, showResult);
|
||||
if (pullResult?.ok) {
|
||||
const pushResult = await replicator.requestSynchroniseToPeer(peerId);
|
||||
resolve(pushResult?.ok ?? true);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger(
|
||||
`Error in bidirectional sync with ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
logLevel
|
||||
);
|
||||
resolve(false);
|
||||
}
|
||||
},
|
||||
onSyncAndClose: async (peerId: string) => {
|
||||
try {
|
||||
const pullResult = await replicator.replicateFrom(peerId, showResult);
|
||||
if (pullResult?.ok) {
|
||||
const pushResult = await replicator.requestSynchroniseToPeer(peerId);
|
||||
if (pushResult?.ok ?? true) {
|
||||
await replicator.close();
|
||||
resolve(true);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger(
|
||||
`Error in bidirectional sync with ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
logLevel
|
||||
);
|
||||
resolve(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
showResult
|
||||
);
|
||||
modal.open();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an openRebuildUI factory for Obsidian environments.
|
||||
* Opens the P2P Replication modal in "rebuild" mode — one-way pull only,
|
||||
* with setOnSetup / clearOnSetup bracketing the replicateFrom call.
|
||||
*
|
||||
* Usage:
|
||||
* const factory = createOpenRebuildUI(app);
|
||||
* useP2PReplicatorFeature(core, createOpenReplicationUI(app), factory);
|
||||
*/
|
||||
export function createOpenRebuildUI(
|
||||
app: App
|
||||
): (replicator: LiveSyncTrysteroReplicator) => (showResult: boolean) => Promise<boolean | void> {
|
||||
return (replicator: LiveSyncTrysteroReplicator) =>
|
||||
(showResult: boolean): Promise<boolean | void> => {
|
||||
const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||
return new Promise<boolean | void>((resolve) => {
|
||||
let resolved = false;
|
||||
const safeResolve = (val: boolean) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(val);
|
||||
}
|
||||
};
|
||||
|
||||
const doRebuild = async (peerId: string) => {
|
||||
replicator.setOnSetup();
|
||||
try {
|
||||
Logger(`Rebuilding from peer ${peerId}`, logLevel);
|
||||
const result = await replicator.replicateFrom(peerId, showResult);
|
||||
safeResolve(result?.ok ?? false);
|
||||
} catch (e) {
|
||||
Logger(
|
||||
`Error in rebuild from ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
logLevel
|
||||
);
|
||||
safeResolve(false);
|
||||
} finally {
|
||||
replicator.clearOnSetup();
|
||||
}
|
||||
};
|
||||
|
||||
const modal = new P2POpenReplicationModal(
|
||||
app,
|
||||
replicator,
|
||||
{
|
||||
onSync: doRebuild,
|
||||
onSyncAndClose: doRebuild,
|
||||
},
|
||||
showResult,
|
||||
"P2P Rebuild",
|
||||
() => safeResolve(false),
|
||||
true
|
||||
);
|
||||
modal.open();
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -5,20 +5,21 @@
|
||||
AcceptedStatus,
|
||||
ConnectionStatus,
|
||||
type PeerStatus,
|
||||
} from "../../../lib/src/replication/trystero/P2PReplicatorPaneCommon";
|
||||
import type { LiveSyncTrysteroReplicator } from "../../../lib/src/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
} from "@lib/replication/trystero/P2PReplicatorPaneCommon";
|
||||
import type { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import PeerStatusRow from "../P2PReplicator/PeerStatusRow.svelte";
|
||||
import { EVENT_LAYOUT_READY, eventHub } from "../../../common/events";
|
||||
import { EVENT_LAYOUT_READY, eventHub } from "@/common/events";
|
||||
import {
|
||||
type PeerInfo,
|
||||
type P2PServerInfo,
|
||||
EVENT_SERVER_STATUS,
|
||||
EVENT_REQUEST_STATUS,
|
||||
EVENT_P2P_REPLICATOR_STATUS,
|
||||
} from "../../../lib/src/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import { type P2PReplicatorStatus } from "../../../lib/src/replication/trystero/TrysteroReplicator";
|
||||
import { $msg as _msg } from "../../../lib/src/common/i18n";
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "../../../lib/src/common/types";
|
||||
} from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import { type P2PReplicatorStatus } from "@lib/replication/trystero/TrysteroReplicator";
|
||||
import { $msg as _msg } from "@lib/common/i18n";
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "@lib/common/types";
|
||||
import { generateP2PRoomId } from "@lib/common/utils";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
|
||||
interface Props {
|
||||
@@ -148,6 +149,7 @@
|
||||
eventHub.emitEvent(EVENT_REQUEST_STATUS);
|
||||
return () => {
|
||||
r();
|
||||
rx();
|
||||
r2();
|
||||
r3();
|
||||
};
|
||||
@@ -216,18 +218,8 @@
|
||||
function useDefaultRelay() {
|
||||
eRelay = DEFAULT_SETTINGS.P2P_relays;
|
||||
}
|
||||
function _generateRandom() {
|
||||
return (Math.floor(Math.random() * 1000) + 1000).toString().substring(1);
|
||||
}
|
||||
function generateRandom(length: number) {
|
||||
let buf = "";
|
||||
while (buf.length < length) {
|
||||
buf += "-" + _generateRandom();
|
||||
}
|
||||
return buf.substring(1, length);
|
||||
}
|
||||
function chooseRandom() {
|
||||
eRoomId = generateRandom(12) + "-" + Math.random().toString(36).substring(2, 5);
|
||||
eRoomId = generateP2PRoomId();
|
||||
}
|
||||
|
||||
async function openServer() {
|
||||
@@ -251,7 +243,7 @@
|
||||
setting?: boolean;
|
||||
};
|
||||
return initialDialogStatus;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
310
src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte
Normal file
310
src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte
Normal file
@@ -0,0 +1,310 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { eventHub } from "@/common/events";
|
||||
import { delay, fireAndForget } from "octagonal-wheels/promises";
|
||||
import type { P2PServerInfo } from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import {
|
||||
EVENT_SERVER_STATUS,
|
||||
EVENT_REQUEST_STATUS,
|
||||
EVENT_P2P_REPLICATOR_STATUS,
|
||||
} from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import { EVENT_SETTING_SAVED } from "@lib/events/coreEvents";
|
||||
import type { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import type { P2PReplicatorStatus } from "@/lib/src/replication/trystero/TrysteroReplicator";
|
||||
import { extractP2PRoomSuffix } from "@/lib/src/common/utils";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
|
||||
interface Props {
|
||||
liveSyncReplicator: LiveSyncTrysteroReplicator;
|
||||
showBroadcastToggle?: boolean;
|
||||
core?: LiveSyncBaseCore;
|
||||
}
|
||||
|
||||
let { liveSyncReplicator, showBroadcastToggle = true, core }: Props = $props();
|
||||
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
|
||||
let replicatorStatus = $state<P2PReplicatorStatus | undefined>(undefined);
|
||||
let roomSuffix = $state<string>(extractP2PRoomSuffix(core?.services.setting.currentSettings()?.P2P_roomID ?? ""));
|
||||
let useDiagRTC = $state<boolean>(core?.services.setting.currentSettings()?.P2P_useDiagRTC ?? false);
|
||||
|
||||
async function requestServerStatus() {
|
||||
await Promise.resolve(liveSyncReplicator.requestStatus());
|
||||
eventHub.emitEvent(EVENT_REQUEST_STATUS);
|
||||
}
|
||||
|
||||
async function onOpenConnection() {
|
||||
await liveSyncReplicator.makeSureOpened();
|
||||
await requestServerStatus();
|
||||
}
|
||||
|
||||
async function onDisconnect() {
|
||||
await liveSyncReplicator.close();
|
||||
await requestServerStatus();
|
||||
}
|
||||
|
||||
function toggleBroadcast() {
|
||||
if (replicatorStatus?.isBroadcasting) {
|
||||
liveSyncReplicator.disableBroadcastChanges();
|
||||
} else {
|
||||
liveSyncReplicator.enableBroadcastChanges();
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDiagRTC() {
|
||||
if (!core) {
|
||||
return;
|
||||
}
|
||||
const next = !useDiagRTC;
|
||||
await core.services.setting.updateSettings((settings) => {
|
||||
settings.P2P_useDiagRTC = next;
|
||||
return settings;
|
||||
}, true);
|
||||
useDiagRTC = next;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
|
||||
serverInfo = status;
|
||||
roomSuffix = extractP2PRoomSuffix(status?.roomId ?? "");
|
||||
});
|
||||
const unsubscribeStatus = eventHub.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
|
||||
replicatorStatus = status;
|
||||
});
|
||||
const unsubscribeSettings = eventHub.onEvent(EVENT_SETTING_SAVED, (settings) => {
|
||||
roomSuffix = extractP2PRoomSuffix(settings?.P2P_roomID ?? "");
|
||||
useDiagRTC = settings?.P2P_useDiagRTC ?? false;
|
||||
});
|
||||
|
||||
fireAndForget(async () => {
|
||||
await delay(100);
|
||||
await requestServerStatus();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
unsubscribeStatus();
|
||||
unsubscribeSettings();
|
||||
};
|
||||
});
|
||||
|
||||
const isConnected = $derived.by(() => serverInfo?.isConnected);
|
||||
const isBroadcasting = $derived.by(() => replicatorStatus?.isBroadcasting ?? false);
|
||||
</script>
|
||||
|
||||
<div class="server-status">
|
||||
<h3>Signalling Status</h3>
|
||||
|
||||
<div class="status-item">
|
||||
<span>Connection:</span>
|
||||
<span class="status-value {isConnected ? 'connected' : 'disconnected'}">
|
||||
{isConnected ? "🟢 Connected" : "🔴 Disconnected"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="status-item status-action">
|
||||
{#if !isConnected}
|
||||
<button onclick={onOpenConnection}>Open connection</button>
|
||||
{:else}
|
||||
<button onclick={onDisconnect}>Close connection</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if serverInfo}
|
||||
<div class="status-item">
|
||||
<span>Room ID suffix:</span>
|
||||
<span class="room-suffix-display" title={roomSuffix || "Not configured"}>
|
||||
{roomSuffix || "-"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="status-item">
|
||||
<span>Peer ID:</span>
|
||||
<span class="peer-id-display" title={serverInfo.serverPeerId}>
|
||||
{serverInfo.serverPeerId.slice(0, 12)}...
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="status-item">
|
||||
<span>Devices:</span>
|
||||
<span>{serverInfo.knownAdvertisements.length}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showBroadcastToggle}
|
||||
<div class="status-item status-action broadcast-row">
|
||||
<!-- Live-push to peers: stream this device's changes to connected peers for LiveSync -->
|
||||
<label class="broadcast-label" for="broadcast-toggle">
|
||||
Live-push to peers
|
||||
</label>
|
||||
<button
|
||||
id="broadcast-toggle"
|
||||
class="broadcast-button {isBroadcasting ? 'is-on' : 'is-off'}"
|
||||
onclick={toggleBroadcast}
|
||||
title={isBroadcasting ? 'Pushing changes to peers — click to stop' : 'Start pushing changes to peers'}
|
||||
>
|
||||
{isBroadcasting ? '📡 On' : '📡 Off'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if core}
|
||||
<div class="status-item status-action diag-toggle-row">
|
||||
<label class="broadcast-label" for="diag-toggle">
|
||||
🕵️ Diag
|
||||
</label>
|
||||
<button
|
||||
id="diag-toggle"
|
||||
class="broadcast-button {useDiagRTC ? 'is-on' : 'is-off'}"
|
||||
onclick={toggleDiagRTC}
|
||||
title={useDiagRTC
|
||||
? 'Diagnostic RTCPeerConnection is enabled'
|
||||
: 'Use Diagnostic RTCPeerConnection for statistics'}
|
||||
>
|
||||
{useDiagRTC ? 'On' : 'Off'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if serverInfo}
|
||||
<div class="diag-section">
|
||||
<h4>Stats</h4>
|
||||
<div class="diag-grid">
|
||||
<div class="diag-item">
|
||||
<span>Incoming:</span>
|
||||
<span>{serverInfo.diag.totalNewConnections}</span>
|
||||
</div>
|
||||
<div class="diag-item">
|
||||
<span>Connected:</span>
|
||||
<span>{serverInfo.diag.totalSuccessfulConnections}</span>
|
||||
</div>
|
||||
<div class="diag-item">
|
||||
<span>Failed:</span>
|
||||
<span>{serverInfo.diag.totalFailedConnections}</span>
|
||||
</div>
|
||||
<div class="diag-item">
|
||||
<span>Closed:</span>
|
||||
<span>{serverInfo.diag.totalClosedConnections}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.server-status {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.status-action {
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-value.connected {
|
||||
color: var(--text-success);
|
||||
}
|
||||
|
||||
.status-value.disconnected {
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
.peer-id-display {
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.room-suffix-display {
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.broadcast-row {
|
||||
align-items: center;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.diag-toggle-row {
|
||||
align-items: center;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.broadcast-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.broadcast-button {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.4rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.broadcast-button.is-on {
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
.broadcast-button.is-off {
|
||||
background-color: var(--interactive-normal);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.broadcast-button.is-off:hover {
|
||||
background-color: var(--interactive-hover);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.diag-section {
|
||||
border-top: 1px solid var(--divider-color);
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.diag-section h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.diag-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
gap: 0.35rem 0.75rem;
|
||||
}
|
||||
|
||||
.diag-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
891
src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte
Normal file
891
src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte
Normal file
@@ -0,0 +1,891 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { EVENT_LAYOUT_READY, EVENT_REQUEST_OPEN_P2P_SETTINGS, eventHub } from "@/common/events";
|
||||
import {
|
||||
EVENT_SERVER_STATUS,
|
||||
EVENT_REQUEST_STATUS,
|
||||
EVENT_P2P_REPLICATOR_STATUS,
|
||||
EVENT_P2P_REPLICATOR_PROGRESS,
|
||||
type P2PServerInfo,
|
||||
} from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import type { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import type { P2PReplicatorStatus, P2PReplicationReport } from "@lib/replication/trystero/TrysteroReplicator";
|
||||
import { delay, fireAndForget } from "octagonal-wheels/promises";
|
||||
import P2PServerStatusCard from "./P2PServerStatusCard.svelte";
|
||||
import { EVENT_SETTING_SAVED } from "@lib/events/coreEvents";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import { ConnectionStringParser } from "@lib/common/ConnectionString";
|
||||
import type { P2PSyncSetting, RemoteConfiguration } from "@lib/common/models/setting.type";
|
||||
import { activateP2PRemoteConfiguration, createRemoteConfigurationId } from "@lib/serviceFeatures/remoteConfig";
|
||||
import { extractP2PRoomSuffix } from "@lib/common/utils";
|
||||
import { SetupManager } from "@/modules/features/SetupManager";
|
||||
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
|
||||
|
||||
interface Props {
|
||||
liveSyncReplicator: LiveSyncTrysteroReplicator;
|
||||
core: LiveSyncBaseCore;
|
||||
}
|
||||
|
||||
let { liveSyncReplicator, core }: Props = $props();
|
||||
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
|
||||
let replicatorInfo = $state<P2PReplicatorStatus | undefined>(undefined);
|
||||
let decidingPeerId = $state<string | null>(null);
|
||||
let replicatingPeerId = $state<string | null>(null);
|
||||
let communicatingUntil = $state<Record<string, number>>({});
|
||||
const COMMUNICATION_HOLD_MS = 2500;
|
||||
let syncOnReplicationSetting = $state(core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "");
|
||||
type P2PRemoteOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
roomSuffix: string;
|
||||
};
|
||||
let p2pRemoteOptions = $state<P2PRemoteOption[]>([]);
|
||||
let selectedP2PRemoteConfigurationId = $state(
|
||||
core.services.setting.currentSettings()?.P2P_ActiveRemoteConfigurationId ?? ""
|
||||
);
|
||||
let selectingP2PRemote = $state(false);
|
||||
|
||||
function addToList(item: string, list: string): string {
|
||||
const items = list
|
||||
.split(",")
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e);
|
||||
if (!items.includes(item)) items.push(item);
|
||||
return items.join(",");
|
||||
}
|
||||
function removeFromList(item: string, list: string): string {
|
||||
return list
|
||||
.split(",")
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e && e !== item)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
function markCommunicating(peerId: string) {
|
||||
const expiry = Date.now() + COMMUNICATION_HOLD_MS;
|
||||
communicatingUntil = { ...communicatingUntil, [peerId]: expiry };
|
||||
window.setTimeout(() => {
|
||||
if ((communicatingUntil[peerId] ?? 0) <= Date.now()) {
|
||||
const { [peerId]: _removed, ...rest } = communicatingUntil;
|
||||
communicatingUntil = rest;
|
||||
}
|
||||
}, COMMUNICATION_HOLD_MS + 100);
|
||||
}
|
||||
|
||||
function listP2PRemoteOptions(
|
||||
remoteConfigurations: Record<string, RemoteConfiguration> | undefined
|
||||
): P2PRemoteOption[] {
|
||||
return Object.values(remoteConfigurations ?? {})
|
||||
.map((config) => {
|
||||
try {
|
||||
const parsed = ConnectionStringParser.parse(config.uri);
|
||||
if (parsed.type !== "p2p") {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
roomSuffix: extractP2PRoomSuffix(parsed.settings.P2P_roomID ?? ""),
|
||||
} as P2PRemoteOption;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
.filter((e): e is P2PRemoteOption => !!e);
|
||||
}
|
||||
|
||||
function refreshP2PRemoteOptions() {
|
||||
const settings = core.services.setting.currentSettings();
|
||||
const options = listP2PRemoteOptions(settings.remoteConfigurations);
|
||||
p2pRemoteOptions = options;
|
||||
const currentSelected = settings.P2P_ActiveRemoteConfigurationId ?? "";
|
||||
const isCurrentSelectedValid = options.some((option) => option.id === currentSelected);
|
||||
if (options.length === 0) {
|
||||
selectedP2PRemoteConfigurationId = "";
|
||||
return;
|
||||
}
|
||||
if (currentSelected.trim() === "" || !isCurrentSelectedValid) {
|
||||
const fallbackId = options[0].id;
|
||||
selectedP2PRemoteConfigurationId = fallbackId;
|
||||
if (currentSelected !== fallbackId) {
|
||||
fireAndForget(() => applyP2PActiveRemoteSelection(fallbackId));
|
||||
}
|
||||
return;
|
||||
}
|
||||
selectedP2PRemoteConfigurationId = currentSelected;
|
||||
}
|
||||
|
||||
function canEditP2PSettings() {
|
||||
const selected = selectedP2PRemoteConfigurationId.trim();
|
||||
if (selected === "") {
|
||||
return false;
|
||||
}
|
||||
return p2pRemoteOptions.some((e) => e.id === selected);
|
||||
}
|
||||
|
||||
async function requestServerStatus() {
|
||||
await liveSyncReplicator.requestStatus();
|
||||
eventHub.emitEvent(EVENT_REQUEST_STATUS);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
|
||||
serverInfo = status;
|
||||
});
|
||||
const unsubscribeReplicatorStatus = eventHub.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
|
||||
replicatorInfo = status;
|
||||
for (const peerId of status.replicatingFrom) {
|
||||
markCommunicating(peerId);
|
||||
}
|
||||
for (const peerId of status.replicatingTo) {
|
||||
markCommunicating(peerId);
|
||||
}
|
||||
});
|
||||
const unsubscribeReplicatorProgress = eventHub.onEvent(EVENT_P2P_REPLICATOR_PROGRESS, (report) => {
|
||||
const rep = report as P2PReplicationReport;
|
||||
if (("fetching" in rep && rep.fetching?.isActive) || ("sending" in rep && rep.sending?.isActive)) {
|
||||
markCommunicating(rep.peerId);
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribeSettings = eventHub.onEvent(EVENT_SETTING_SAVED, (settings) => {
|
||||
syncOnReplicationSetting = settings?.P2P_SyncOnReplication ?? "";
|
||||
refreshP2PRemoteOptions();
|
||||
});
|
||||
const unsubscribeLayoutReady = eventHub.onEvent(EVENT_LAYOUT_READY, () => {
|
||||
refreshP2PRemoteOptions();
|
||||
void requestServerStatus();
|
||||
});
|
||||
|
||||
fireAndForget(async () => {
|
||||
await delay(100);
|
||||
refreshP2PRemoteOptions();
|
||||
await requestServerStatus();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
unsubscribeReplicatorStatus();
|
||||
unsubscribeReplicatorProgress();
|
||||
unsubscribeSettings();
|
||||
unsubscribeLayoutReady();
|
||||
};
|
||||
});
|
||||
|
||||
function getAcceptanceStatus(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||
if (peer.isTemporaryAccepted === true) return "ACCEPTED (in session)";
|
||||
if (peer.isAccepted === true) return "ACCEPTED";
|
||||
if (peer.isTemporaryAccepted === false) return "DENIED (in session)";
|
||||
if (peer.isAccepted === false) return "DENIED";
|
||||
return "NEW";
|
||||
}
|
||||
|
||||
function getAcceptanceStatusClass(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||
if (peer.isTemporaryAccepted === true || peer.isAccepted === true) return "accepted";
|
||||
if (peer.isTemporaryAccepted === false || peer.isAccepted === false) return "denied";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function openConnectionSettings() {
|
||||
eventHub.emitEvent(EVENT_REQUEST_OPEN_P2P_SETTINGS);
|
||||
}
|
||||
|
||||
async function applyP2PActiveRemoteSelection(id: string) {
|
||||
selectingP2PRemote = true;
|
||||
try {
|
||||
await core.services.setting.updateSettings((settings) => {
|
||||
settings.P2P_ActiveRemoteConfigurationId = id;
|
||||
if (id.trim() === "") {
|
||||
return settings;
|
||||
}
|
||||
const activated = activateP2PRemoteConfiguration(settings, id);
|
||||
return activated || settings;
|
||||
}, true);
|
||||
const latest = core.services.setting.currentSettings();
|
||||
syncOnReplicationSetting = latest.P2P_SyncOnReplication ?? "";
|
||||
refreshP2PRemoteOptions();
|
||||
} finally {
|
||||
selectingP2PRemote = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onP2PRemoteSelected(event: Event) {
|
||||
const target = event.currentTarget as HTMLSelectElement;
|
||||
const id = target.value;
|
||||
selectedP2PRemoteConfigurationId = id;
|
||||
await applyP2PActiveRemoteSelection(id);
|
||||
}
|
||||
|
||||
async function createAndSelectP2PRemote() {
|
||||
const setupManager = core.getModule(SetupManager);
|
||||
const dialogManager = setupManager.dialogManager;
|
||||
const currentSettings = core.services.setting.currentSettings();
|
||||
const p2pConf = await dialogManager.openWithExplicitCancel(SetupRemoteP2P, currentSettings);
|
||||
if (p2pConf === "cancelled" || typeof p2pConf !== "object" || !p2pConf) {
|
||||
return;
|
||||
}
|
||||
const p2pSettings = p2pConf as Partial<P2PSyncSetting>;
|
||||
const id = createRemoteConfigurationId();
|
||||
const roomSuffix = extractP2PRoomSuffix(p2pSettings.P2P_roomID ?? "");
|
||||
const name = roomSuffix ? `P2P Remote (${roomSuffix})` : "P2P Remote";
|
||||
await core.services.setting.updateSettings((settings) => {
|
||||
const merged = {
|
||||
...settings,
|
||||
...p2pSettings,
|
||||
};
|
||||
const uri = ConnectionStringParser.serialize({ type: "p2p", settings: merged });
|
||||
settings.remoteConfigurations = {
|
||||
...(settings.remoteConfigurations ?? {}),
|
||||
[id]: {
|
||||
id,
|
||||
name,
|
||||
uri,
|
||||
isEncrypted: false,
|
||||
},
|
||||
};
|
||||
settings.P2P_ActiveRemoteConfigurationId = id;
|
||||
const activated = activateP2PRemoteConfiguration(settings, id);
|
||||
return activated || settings;
|
||||
}, true);
|
||||
const latest = core.services.setting.currentSettings();
|
||||
syncOnReplicationSetting = latest.P2P_SyncOnReplication ?? "";
|
||||
refreshP2PRemoteOptions();
|
||||
}
|
||||
|
||||
async function updateSelectedP2PRemote(partial: Partial<P2PSyncSetting>) {
|
||||
const selectedId = core.services.setting.currentSettings().P2P_ActiveRemoteConfigurationId?.trim() ?? "";
|
||||
if (selectedId === "") {
|
||||
return;
|
||||
}
|
||||
await core.services.setting.updateSettings((settings) => {
|
||||
const config = settings.remoteConfigurations?.[selectedId];
|
||||
if (!config) {
|
||||
return settings;
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = ConnectionStringParser.parse(config.uri);
|
||||
} catch {
|
||||
return settings;
|
||||
}
|
||||
if (parsed.type !== "p2p") {
|
||||
return settings;
|
||||
}
|
||||
const mergedP2P = {
|
||||
...parsed.settings,
|
||||
...partial,
|
||||
};
|
||||
const uri = ConnectionStringParser.serialize({
|
||||
type: "p2p",
|
||||
settings: {
|
||||
...settings,
|
||||
...mergedP2P,
|
||||
},
|
||||
});
|
||||
settings.remoteConfigurations = {
|
||||
...(settings.remoteConfigurations ?? {}),
|
||||
[selectedId]: {
|
||||
...config,
|
||||
uri,
|
||||
isEncrypted: false,
|
||||
},
|
||||
};
|
||||
Object.assign(settings, partial);
|
||||
const activated = activateP2PRemoteConfiguration(settings, selectedId);
|
||||
return activated || settings;
|
||||
}, true);
|
||||
syncOnReplicationSetting = core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "";
|
||||
}
|
||||
|
||||
async function makeDecision(
|
||||
peer: P2PServerInfo["knownAdvertisements"][number],
|
||||
decision: boolean,
|
||||
isTemporary: boolean
|
||||
) {
|
||||
decidingPeerId = peer.peerId;
|
||||
try {
|
||||
await liveSyncReplicator.makeDecision({
|
||||
peerId: peer.peerId,
|
||||
name: peer.name,
|
||||
decision,
|
||||
isTemporary,
|
||||
});
|
||||
await requestServerStatus();
|
||||
} finally {
|
||||
decidingPeerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeDecision(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||
decidingPeerId = peer.peerId;
|
||||
try {
|
||||
await liveSyncReplicator.revokeDecision({
|
||||
peerId: peer.peerId,
|
||||
name: peer.name,
|
||||
});
|
||||
await requestServerStatus();
|
||||
} finally {
|
||||
decidingPeerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function startReplication(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||
replicatingPeerId = peer.peerId;
|
||||
try {
|
||||
const pullResult = await liveSyncReplicator.replicateFrom(peer.peerId, true);
|
||||
if (pullResult?.ok) {
|
||||
await liveSyncReplicator.requestSynchroniseToPeer(peer.peerId);
|
||||
}
|
||||
await requestServerStatus();
|
||||
} finally {
|
||||
replicatingPeerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function isAccepted(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||
return peer.isTemporaryAccepted === true || peer.isAccepted === true;
|
||||
}
|
||||
|
||||
function isWatching(peerId: string) {
|
||||
return replicatorInfo?.watchingPeers?.includes(peerId) ?? false;
|
||||
}
|
||||
|
||||
function toggleWatch(peerId: string) {
|
||||
if (!canEditP2PSettings()) {
|
||||
return;
|
||||
}
|
||||
if (isWatching(peerId)) {
|
||||
liveSyncReplicator.unwatchPeer(peerId);
|
||||
} else {
|
||||
liveSyncReplicator.watchPeer(peerId);
|
||||
}
|
||||
}
|
||||
|
||||
function isCommunicating(peerId: string) {
|
||||
const to = replicatorInfo?.replicatingTo ?? [];
|
||||
const from = replicatorInfo?.replicatingFrom ?? [];
|
||||
const isLiveCommunicating = to.includes(peerId) || from.includes(peerId);
|
||||
const isHeldCommunicating = (communicatingUntil[peerId] ?? 0) > Date.now();
|
||||
return isLiveCommunicating || isHeldCommunicating;
|
||||
}
|
||||
|
||||
function isSyncTarget(peerName: string) {
|
||||
return syncOnReplicationSetting
|
||||
.split(",")
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e)
|
||||
.includes(peerName);
|
||||
}
|
||||
|
||||
async function toggleSyncTarget(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||
if (!canEditP2PSettings()) {
|
||||
return;
|
||||
}
|
||||
const currentValue = core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "";
|
||||
const newValue = isSyncTarget(peer.name)
|
||||
? removeFromList(peer.name, currentValue)
|
||||
: addToList(peer.name, currentValue);
|
||||
await updateSelectedP2PRemote({ P2P_SyncOnReplication: newValue });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p2p-container">
|
||||
<div class="pane-header">
|
||||
<h2>P2P Status</h2>
|
||||
<div class="pane-header-actions">
|
||||
<div class="remote-picker-wrap">
|
||||
<select
|
||||
class="remote-picker"
|
||||
value={selectedP2PRemoteConfigurationId}
|
||||
onchange={onP2PRemoteSelected}
|
||||
disabled={selectingP2PRemote}
|
||||
aria-label="Select active P2P remote"
|
||||
title="Select active P2P remote"
|
||||
>
|
||||
{#if p2pRemoteOptions.length === 0}
|
||||
<option value="">Select P2P remote...</option>
|
||||
{/if}
|
||||
{#each p2pRemoteOptions as option}
|
||||
<option value={option.id}>
|
||||
{option.name}{option.roomSuffix ? ` (${option.roomSuffix})` : ""}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
class="icon-button"
|
||||
onclick={() => createAndSelectP2PRemote()}
|
||||
title="Create P2P remote"
|
||||
aria-label="Create P2P remote"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="icon-button"
|
||||
onclick={openConnectionSettings}
|
||||
title="Open P2P Setup..."
|
||||
aria-label="Open P2P Setup..."
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !canEditP2PSettings()}
|
||||
<p class="warning-line">Please select an active P2P remote configuration to change P2P sync targets.</p>
|
||||
{/if}
|
||||
|
||||
<P2PServerStatusCard {liveSyncReplicator} {core} />
|
||||
|
||||
<div class="peers-section">
|
||||
<div class="peers-header">
|
||||
<h3>Detected Peers</h3>
|
||||
<button class="refresh" onclick={requestServerStatus}>Refresh</button>
|
||||
</div>
|
||||
|
||||
{#if serverInfo && serverInfo.knownAdvertisements.length > 0}
|
||||
<div class="peers-list">
|
||||
{#each serverInfo.knownAdvertisements as peer (peer.peerId)}
|
||||
<div class="peer-item">
|
||||
<div class="peer-info">
|
||||
<div class="peer-name">
|
||||
{peer.name} :
|
||||
<span class="peer-id-mini" title={peer.peerId}>({peer.peerId.slice(0, 8)})</span>
|
||||
{#if isCommunicating(peer.peerId)}
|
||||
<span class="comm-icon" title="Communicating" aria-label="Communicating">📡</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="peer-meta">
|
||||
<span class="badge">{peer.platform}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="peer-actions">
|
||||
{#if isAccepted(peer)}
|
||||
<div class="decision-row accepted-row">
|
||||
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
|
||||
{getAcceptanceStatus(peer)}
|
||||
</span>
|
||||
<button
|
||||
class="emoji-button"
|
||||
disabled={replicatingPeerId !== null}
|
||||
title={replicatingPeerId === peer.peerId ? "Replicating..." : "Replicate now"}
|
||||
aria-label={replicatingPeerId === peer.peerId ? "Replicating" : "Replicate now"}
|
||||
onclick={() => startReplication(peer)}
|
||||
>
|
||||
{replicatingPeerId === peer.peerId ? "⏳" : "🔄"}
|
||||
</button>
|
||||
<button
|
||||
class="action-button"
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => revokeDecision(peer)}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
<div class="decision-row watch-row">
|
||||
<span class="decision-label">WATCH</span>
|
||||
<button
|
||||
class="emoji-button {isWatching(peer.peerId) ? 'is-watching' : ''}"
|
||||
title={isWatching(peer.peerId)
|
||||
? "Watching this peer \u2014 click to stop"
|
||||
: "Watch this peer's changes"}
|
||||
aria-label={isWatching(peer.peerId) ? "Stop watching" : "Watch peer"}
|
||||
disabled={!canEditP2PSettings()}
|
||||
onclick={() => toggleWatch(peer.peerId)}
|
||||
>
|
||||
{isWatching(peer.peerId) ? "🔔" : "🔕"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="decision-row watch-row">
|
||||
<span class="decision-label">SYNC</span>
|
||||
<button
|
||||
class="emoji-button {isSyncTarget(peer.name) ? 'is-watching' : ''}"
|
||||
title={isSyncTarget(peer.name)
|
||||
? "Sync target \u2014 click to remove"
|
||||
: "Set as sync target"}
|
||||
aria-label={isSyncTarget(peer.name) ? "Remove sync target" : "Set sync target"}
|
||||
disabled={!canEditP2PSettings()}
|
||||
onclick={() => toggleSyncTarget(peer)}
|
||||
>
|
||||
{isSyncTarget(peer.name) ? "🔗" : "⛓️💥"}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="decision-status">
|
||||
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
|
||||
{getAcceptanceStatus(peer)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="decision-row">
|
||||
<span class="decision-label">PERMANENT</span>
|
||||
<button
|
||||
class="emoji-button"
|
||||
title="Allow permanently"
|
||||
aria-label="Allow permanently"
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, true, false)}
|
||||
>
|
||||
✅
|
||||
</button>
|
||||
<button
|
||||
class="emoji-button mod-warning"
|
||||
title="Deny permanently"
|
||||
aria-label="Deny permanently"
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, false, false)}
|
||||
>
|
||||
🚫
|
||||
</button>
|
||||
</div>
|
||||
<div class="decision-row">
|
||||
<span class="decision-label">SESSION</span>
|
||||
<button
|
||||
class="emoji-button"
|
||||
title="Allow in session"
|
||||
aria-label="Allow in session"
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, true, true)}
|
||||
>
|
||||
✅
|
||||
</button>
|
||||
<button
|
||||
class="emoji-button mod-warning"
|
||||
title="Deny in session"
|
||||
aria-label="Deny in session"
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, false, true)}
|
||||
>
|
||||
🚫
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !isAccepted(peer) && (peer.isAccepted !== undefined || peer.isTemporaryAccepted !== undefined)}
|
||||
<button
|
||||
class="action-button revoke-inline"
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => revokeDecision(peer)}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if serverInfo}
|
||||
<p class="no-peers">No devices available. Waiting for other devices to connect...</p>
|
||||
{:else}
|
||||
<p class="no-peers">Fetching status...</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.p2p-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.peers-section {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.pane-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pane-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.remote-picker-wrap {
|
||||
display: inline-flex;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.remote-picker {
|
||||
max-width: 10rem;
|
||||
min-width: 1em;
|
||||
flex-shrink: 1;
|
||||
height: 1.9rem;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.4rem;
|
||||
background-color: var(--interactive-normal);
|
||||
color: var(--text-normal);
|
||||
padding: 0 0.45rem;
|
||||
}
|
||||
|
||||
.warning-line {
|
||||
margin: -0.2rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-warning);
|
||||
}
|
||||
|
||||
.pane-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.4rem;
|
||||
background-color: var(--interactive-normal);
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
background-color: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.peers-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.refresh {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.3rem;
|
||||
background-color: var(--interactive-normal);
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.refresh:hover {
|
||||
background-color: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.peers-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.peer-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--background-secondary);
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
|
||||
.peer-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.peer-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.peer-meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background-color: var(--background-tertiary);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-chip.accepted {
|
||||
background-color: var(--background-modifier-success);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.status-chip.denied {
|
||||
background-color: var(--background-modifier-error);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.status-chip.unknown {
|
||||
background-color: var(--background-modifier-border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.peer-id-mini {
|
||||
font-family: monospace;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.comm-icon {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1;
|
||||
animation: pulse-comm 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-comm {
|
||||
0% {
|
||||
opacity: 0.55;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.peer-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.decision-status {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.decision-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.accepted-row {
|
||||
grid-template-columns: 1fr auto auto;
|
||||
}
|
||||
|
||||
.decision-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.3rem;
|
||||
background-color: var(--interactive-normal);
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.emoji-button {
|
||||
width: 2rem;
|
||||
height: 1.7rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.3rem;
|
||||
background-color: var(--interactive-normal);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.emoji-button.mod-warning {
|
||||
background-color: var(--background-modifier-error);
|
||||
}
|
||||
|
||||
.emoji-button.is-watching {
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
.emoji-button:hover:not(:disabled) {
|
||||
background-color: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.emoji-button.mod-warning:hover:not(:disabled) {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.watch-row {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.action-button:hover:not(:disabled) {
|
||||
background-color: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.action-button.mod-warning {
|
||||
background-color: var(--background-modifier-error);
|
||||
}
|
||||
|
||||
.action-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.emoji-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.revoke-inline {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.no-peers {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { WorkspaceLeaf } from "@/deps.ts";
|
||||
import { mount } from "svelte";
|
||||
import { SvelteItemView } from "@/common/SvelteItemView.ts";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||
import type { P2PPaneParams } from "@/lib/src/replication/trystero/UseP2PReplicatorResult";
|
||||
import P2PServerStatusPane from "./P2PServerStatusPane.svelte";
|
||||
|
||||
export const VIEW_TYPE_P2P_SERVER_STATUS = "p2p-server-status";
|
||||
|
||||
export class P2PServerStatusPaneView extends SvelteItemView {
|
||||
core: LiveSyncBaseCore;
|
||||
private _p2pResult: P2PPaneParams;
|
||||
override icon = "waypoints";
|
||||
override navigation = false;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, core: LiveSyncBaseCore, p2pResult: P2PPaneParams) {
|
||||
super(leaf);
|
||||
this.core = core;
|
||||
this._p2pResult = p2pResult;
|
||||
}
|
||||
|
||||
override getIcon(): string {
|
||||
return "waypoints";
|
||||
}
|
||||
|
||||
getViewType() {
|
||||
return VIEW_TYPE_P2P_SERVER_STATUS;
|
||||
}
|
||||
|
||||
getDisplayText() {
|
||||
return "P2P Status";
|
||||
}
|
||||
|
||||
instantiateComponent(target: HTMLElement) {
|
||||
return mount(P2PServerStatusPane, {
|
||||
target,
|
||||
props: {
|
||||
liveSyncReplicator: this._p2pResult.replicator,
|
||||
core: this.core,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
2
src/lib
2
src/lib
Submodule src/lib updated: 91b5981219...e97ca5821c
17
src/main.ts
17
src/main.ts
@@ -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 { HiddenFileSync } from "./features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { ConfigSync } from "./features/ConfigSync/CmdConfigSync.ts";
|
||||
@@ -43,6 +44,7 @@ import { useSetupManagerHandlersFeature } from "./serviceFeatures/setupObsidian/
|
||||
import { useP2PReplicatorFeature } from "@lib/replication/trystero/useP2PReplicatorFeature.ts";
|
||||
import { useP2PReplicatorCommands } from "@lib/replication/trystero/useP2PReplicatorCommands.ts";
|
||||
import { useP2PReplicatorUI } from "./serviceFeatures/useP2PReplicatorUI.ts";
|
||||
import { createOpenReplicationUI, createOpenRebuildUI } from "./features/P2PSync/P2PReplicator/P2PReplicationUI.ts";
|
||||
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
|
||||
export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
core: LiveSyncCore;
|
||||
@@ -175,7 +177,13 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
const curriedFeature = () => featuresInitialiser(core);
|
||||
core.services.appLifecycle.onLayoutReady.addHandler(curriedFeature);
|
||||
const setupManager = core.getModule(SetupManager);
|
||||
|
||||
const replicator = useP2PReplicatorFeature(
|
||||
core,
|
||||
createOpenReplicationUI(this.app),
|
||||
createOpenRebuildUI(this.app)
|
||||
);
|
||||
useP2PReplicatorCommands(core, replicator);
|
||||
useP2PReplicatorUI(core, core, replicator);
|
||||
useRemoteConfiguration(core);
|
||||
|
||||
useSetupProtocolFeature(core, setupManager);
|
||||
@@ -189,9 +197,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
// VIEW_TYPE_P2P,
|
||||
// (leaf: any) => new P2PReplicatorPaneView(leaf, core, p2pReplicatorResult!),
|
||||
// ]);
|
||||
const replicator = useP2PReplicatorFeature(core);
|
||||
useP2PReplicatorCommands(core, replicator);
|
||||
useP2PReplicatorUI(core, core, replicator);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type PouchDB from "pouchdb-core";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { AbstractModule } from "../AbstractModule";
|
||||
import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "octagonal-wheels/common/logger";
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { REMOTE_P2P, type RemoteDBSettings } from "../../lib/src/common/types";
|
||||
import type { LiveSyncAbstractReplicator } from "../../lib/src/replication/LiveSyncAbstractReplicator";
|
||||
import { AbstractModule } from "../AbstractModule";
|
||||
import { LiveSyncTrysteroReplicator } from "../../lib/src/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import type { LiveSyncCore } from "../../main";
|
||||
|
||||
// Note:
|
||||
// This module registers only the `getNewReplicator` handler for the P2P replicator.
|
||||
// `useP2PReplicator` (see P2PReplicatorCore.ts) already registers the same `getNewReplicator`
|
||||
// handler internally, so this module is redundant in environments that call `useP2PReplicator`.
|
||||
// Register this module only in environments that do NOT use `useP2PReplicator` (e.g. CLI).
|
||||
// In other words: just resolving `getNewReplicator` via this module is all that is needed
|
||||
// to satisfy what `useP2PReplicator` requires from the replicator service.
|
||||
export class ModuleReplicatorP2P extends AbstractModule {
|
||||
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
|
||||
const settings = { ...this.settings, ...settingOverride };
|
||||
if (settings.remoteType == REMOTE_P2P) {
|
||||
return Promise.resolve(new LiveSyncTrysteroReplicator(this.core));
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.replicator.getNewReplicator.addHandler(this._anyNewReplicator.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { normalizePath } from "../../deps.ts";
|
||||
import {
|
||||
FlagFilesHumanReadable,
|
||||
FlagFilesOriginal,
|
||||
REMOTE_MINIO,
|
||||
TweakValuesShouldMatchedTemplate,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
import FetchEverything from "../features/SetupWizard/dialogs/FetchEverything.svelte";
|
||||
import RebuildEverything from "../features/SetupWizard/dialogs/RebuildEverything.svelte";
|
||||
import { extractObject } from "octagonal-wheels/object";
|
||||
import { SvelteDialogManagerBase } from "@lib/UI/svelteDialog.ts";
|
||||
import type { ServiceContext } from "@lib/services/base/ServiceBase.ts";
|
||||
|
||||
export class ModuleRedFlag extends AbstractModule {
|
||||
async isFlagFileExist(path: string) {
|
||||
const redflag = await this.core.storageAccess.isExists(normalizePath(path));
|
||||
if (redflag) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async deleteFlagFile(path: string) {
|
||||
try {
|
||||
const isFlagged = await this.core.storageAccess.isExists(normalizePath(path));
|
||||
if (isFlagged) {
|
||||
await this.core.storageAccess.delete(path, true);
|
||||
}
|
||||
} catch (ex) {
|
||||
this._log(`Could not delete ${path}`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
}
|
||||
|
||||
isSuspendFlagActive = async () => await this.isFlagFileExist(FlagFilesOriginal.SUSPEND_ALL);
|
||||
isRebuildFlagActive = async () =>
|
||||
(await this.isFlagFileExist(FlagFilesOriginal.REBUILD_ALL)) ||
|
||||
(await this.isFlagFileExist(FlagFilesHumanReadable.REBUILD_ALL));
|
||||
isFetchAllFlagActive = async () =>
|
||||
(await this.isFlagFileExist(FlagFilesOriginal.FETCH_ALL)) ||
|
||||
(await this.isFlagFileExist(FlagFilesHumanReadable.FETCH_ALL));
|
||||
|
||||
async cleanupRebuildFlag() {
|
||||
await this.deleteFlagFile(FlagFilesOriginal.REBUILD_ALL);
|
||||
await this.deleteFlagFile(FlagFilesHumanReadable.REBUILD_ALL);
|
||||
}
|
||||
|
||||
async cleanupFetchAllFlag() {
|
||||
await this.deleteFlagFile(FlagFilesOriginal.FETCH_ALL);
|
||||
await this.deleteFlagFile(FlagFilesHumanReadable.FETCH_ALL);
|
||||
}
|
||||
// dialogManager = new SvelteDialogManagerBase(this.core);
|
||||
get dialogManager(): SvelteDialogManagerBase<ServiceContext> {
|
||||
return this.core.services.UI.dialogManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust setting to remote if needed.
|
||||
* @param extra result of dialogues that may contain preventFetchingConfig flag (e.g, from FetchEverything or RebuildEverything)
|
||||
* @param config current configuration to retrieve remote preferred config
|
||||
*/
|
||||
async adjustSettingToRemoteIfNeeded(extra: { preventFetchingConfig: boolean }, config: ObsidianLiveSyncSettings) {
|
||||
if (extra && extra.preventFetchingConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remote configuration fetched and applied.
|
||||
if (await this.adjustSettingToRemote(config)) {
|
||||
config = this.core.settings;
|
||||
} else {
|
||||
this._log("Remote configuration not applied.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
console.debug(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust setting to remote configuration.
|
||||
* @param config current configuration to retrieve remote preferred config
|
||||
* @returns updated configuration if applied, otherwise null.
|
||||
*/
|
||||
async adjustSettingToRemote(config: ObsidianLiveSyncSettings) {
|
||||
// Fetch remote configuration unless prevented.
|
||||
const SKIP_FETCH = "Skip and proceed";
|
||||
const RETRY_FETCH = "Retry (recommended)";
|
||||
let canProceed = false;
|
||||
do {
|
||||
const remoteTweaks = await this.services.tweakValue.fetchRemotePreferred(config);
|
||||
if (!remoteTweaks) {
|
||||
const choice = await this.core.confirm.askSelectStringDialogue(
|
||||
"Could not fetch configuration from remote. If you are new to the Self-hosted LiveSync, this might be expected. If not, you should check your network or server settings.",
|
||||
[SKIP_FETCH, RETRY_FETCH] as const,
|
||||
{
|
||||
defaultAction: RETRY_FETCH,
|
||||
timeout: 0,
|
||||
title: "Fetch Remote Configuration Failed",
|
||||
}
|
||||
);
|
||||
if (choice === SKIP_FETCH) {
|
||||
canProceed = true;
|
||||
}
|
||||
} else {
|
||||
const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks);
|
||||
// Check if any necessary tweak value is different from current config.
|
||||
const differentItems = Object.entries(necessary).filter(([key, value]) => {
|
||||
return (config as any)[key] !== value;
|
||||
});
|
||||
if (differentItems.length === 0) {
|
||||
this._log(
|
||||
"Remote configuration matches local configuration. No changes applied.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
} else {
|
||||
await this.core.confirm.askSelectStringDialogue(
|
||||
"Your settings differed slightly from the server's. The plug-in has supplemented the incompatible parts with the server settings!",
|
||||
["OK"] as const,
|
||||
{
|
||||
defaultAction: "OK",
|
||||
timeout: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
config = {
|
||||
...config,
|
||||
...Object.fromEntries(differentItems),
|
||||
} satisfies ObsidianLiveSyncSettings;
|
||||
this.core.settings = config;
|
||||
await this.core.services.setting.saveSettingData();
|
||||
this._log("Remote configuration applied.", LOG_LEVEL_NOTICE);
|
||||
canProceed = true;
|
||||
return this.core.settings;
|
||||
}
|
||||
} while (!canProceed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process vault initialisation with suspending file watching and sync.
|
||||
* @param proc process to be executed during initialisation, should return true if can be continued, false if app is unable to continue the process.
|
||||
* @param keepSuspending whether to keep suspending file watching after the process.
|
||||
* @returns result of the process, or false if error occurs.
|
||||
*/
|
||||
async processVaultInitialisation(proc: () => Promise<boolean>, keepSuspending = false) {
|
||||
try {
|
||||
// Disable batch saving and file watching during initialisation.
|
||||
this.settings.batchSave = false;
|
||||
await this.services.setting.suspendAllSync();
|
||||
await this.services.setting.suspendExtraSync();
|
||||
this.settings.suspendFileWatching = true;
|
||||
await this.saveSettings();
|
||||
try {
|
||||
const result = await proc();
|
||||
return result;
|
||||
} catch (ex) {
|
||||
this._log("Error during vault initialisation process.", LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
} catch (ex) {
|
||||
this._log("Error during vault initialisation.", LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
} finally {
|
||||
if (!keepSuspending) {
|
||||
// Re-enable file watching after initialisation.
|
||||
this.settings.suspendFileWatching = false;
|
||||
await this.saveSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the rebuild everything scheduled operation.
|
||||
* @returns true if can be continued, false if app restart is needed.
|
||||
*/
|
||||
async onRebuildEverythingScheduled() {
|
||||
const method = await this.dialogManager.openWithExplicitCancel(RebuildEverything);
|
||||
if (method === "cancelled") {
|
||||
// Clean up the flag file and restart the app.
|
||||
this._log("Rebuild everything cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
await this.cleanupRebuildFlag();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
const { extra } = method;
|
||||
await this.adjustSettingToRemoteIfNeeded(extra, this.settings);
|
||||
return await this.processVaultInitialisation(async () => {
|
||||
await this.core.rebuilder.$rebuildEverything();
|
||||
await this.cleanupRebuildFlag();
|
||||
this._log("Rebuild everything operation completed.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Handle the fetch all scheduled operation.
|
||||
* @returns true if can be continued, false if app restart is needed.
|
||||
*/
|
||||
async onFetchAllScheduled() {
|
||||
const method = await this.dialogManager.openWithExplicitCancel(FetchEverything);
|
||||
if (method === "cancelled") {
|
||||
this._log("Fetch everything cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
// Clean up the flag file and restart the app.
|
||||
await this.cleanupFetchAllFlag();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
const { vault, extra } = method;
|
||||
// If remote is MinIO, makeLocalChunkBeforeSync is not available. (because no-deduplication on sending).
|
||||
const makeLocalChunkBeforeSyncAvailable = this.settings.remoteType !== REMOTE_MINIO;
|
||||
const mapVaultStateToAction = {
|
||||
identical: {
|
||||
// If both are identical, no need to make local files/chunks before sync,
|
||||
// Just for the efficiency, chunks should be made before sync.
|
||||
makeLocalChunkBeforeSync: makeLocalChunkBeforeSyncAvailable,
|
||||
makeLocalFilesBeforeSync: false,
|
||||
},
|
||||
independent: {
|
||||
// If both are independent, nothing needs to be made before sync.
|
||||
// Respect the remote state.
|
||||
makeLocalChunkBeforeSync: false,
|
||||
makeLocalFilesBeforeSync: false,
|
||||
},
|
||||
unbalanced: {
|
||||
// If both are unbalanced, local files should be made before sync to avoid data loss.
|
||||
// Then, chunks should be made before sync for the efficiency, but also the metadata made and should be detected as conflicting.
|
||||
makeLocalChunkBeforeSync: false,
|
||||
makeLocalFilesBeforeSync: true,
|
||||
},
|
||||
cancelled: {
|
||||
// Cancelled case, not actually used.
|
||||
makeLocalChunkBeforeSync: false,
|
||||
makeLocalFilesBeforeSync: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
return await this.processVaultInitialisation(async () => {
|
||||
await this.adjustSettingToRemoteIfNeeded(extra, this.settings);
|
||||
// Okay, proceed to fetch everything.
|
||||
const { makeLocalChunkBeforeSync, makeLocalFilesBeforeSync } = mapVaultStateToAction[vault];
|
||||
this._log(
|
||||
`Fetching everything with settings: makeLocalChunkBeforeSync=${makeLocalChunkBeforeSync}, makeLocalFilesBeforeSync=${makeLocalFilesBeforeSync}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
await this.core.rebuilder.$fetchLocal(makeLocalChunkBeforeSync, !makeLocalFilesBeforeSync);
|
||||
await this.cleanupFetchAllFlag();
|
||||
this._log("Fetch everything operation completed. Vault files will be gradually synced.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async onSuspendAllScheduled() {
|
||||
this._log("SCRAM is detected. All operations are suspended.", LOG_LEVEL_NOTICE);
|
||||
return await this.processVaultInitialisation(async () => {
|
||||
this._log(
|
||||
"All operations are suspended as per SCRAM.\nLogs will be written to the file. This might be a performance impact.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
this.settings.writeLogToTheFile = true;
|
||||
await this.core.services.setting.saveSettingData();
|
||||
return Promise.resolve(false);
|
||||
}, true);
|
||||
}
|
||||
|
||||
async verifyAndUnlockSuspension() {
|
||||
if (!this.settings.suspendFileWatching) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
"Do you want to resume file and database processing, and restart obsidian now?",
|
||||
{ defaultOption: "Yes", timeout: 15 }
|
||||
)) != "yes"
|
||||
) {
|
||||
// TODO: Confirm actually proceed to next process.
|
||||
return true;
|
||||
}
|
||||
this.settings.suspendFileWatching = false;
|
||||
await this.saveSettings();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
|
||||
private async processFlagFilesOnStartup(): Promise<boolean> {
|
||||
const isFlagSuspensionActive = await this.isSuspendFlagActive();
|
||||
const isFlagRebuildActive = await this.isRebuildFlagActive();
|
||||
const isFlagFetchAllActive = await this.isFetchAllFlagActive();
|
||||
// TODO: Address the case when both flags are active (very unlikely though).
|
||||
// if(isFlagFetchAllActive && isFlagRebuildActive) {
|
||||
// const message = "Rebuild everything and Fetch everything flags are both detected.";
|
||||
// await this.core.confirm.askSelectStringDialogue(
|
||||
// "Both Rebuild Everything and Fetch Everything flags are detected. Please remove one of them and restart the app.",
|
||||
// ["OK"] as const,)
|
||||
if (isFlagFetchAllActive) {
|
||||
const res = await this.onFetchAllScheduled();
|
||||
if (res) {
|
||||
return await this.verifyAndUnlockSuspension();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (isFlagRebuildActive) {
|
||||
const res = await this.onRebuildEverythingScheduled();
|
||||
if (res) {
|
||||
return await this.verifyAndUnlockSuspension();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (isFlagSuspensionActive) {
|
||||
const res = await this.onSuspendAllScheduled();
|
||||
return res;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async _everyOnLayoutReady(): Promise<boolean> {
|
||||
try {
|
||||
const flagProcessResult = await this.processFlagFilesOnStartup();
|
||||
return flagProcessResult;
|
||||
} catch (ex) {
|
||||
this._log("Something went wrong on FlagFile Handling", LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
super.onBindFunction(core, services);
|
||||
services.appLifecycle.onLayoutReady.addHandler(this._everyOnLayoutReady.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import { Logger, LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||
import { extractObject } from "octagonal-wheels/object";
|
||||
import {
|
||||
TweakValuesShouldMatchedTemplate,
|
||||
TweakValuesTemplate,
|
||||
IncompatibleChanges,
|
||||
confName,
|
||||
type TweakValues,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type RemoteDBSettings,
|
||||
IncompatibleChangesInSpecificPattern,
|
||||
CompatibleButLossyChanges,
|
||||
@@ -14,8 +16,107 @@ import { AbstractModule } from "../AbstractModule.ts";
|
||||
import { $msg } from "../../lib/src/common/i18n.ts";
|
||||
import type { InjectableServiceHub } from "../../lib/src/services/InjectableServices.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
import { REMOTE_P2P } from "@lib/common/models/setting.const.ts";
|
||||
|
||||
function valueToString(value: any) {
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return `${value}`;
|
||||
}
|
||||
|
||||
export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
private _hasNotifiedAutoAcceptCompatibleUndefined = false;
|
||||
|
||||
private _collectMismatchedTweakKeys(current: TweakValues, preferred: Partial<TweakValues>) {
|
||||
const items = Object.keys(
|
||||
TweakValuesShouldMatchedTemplate
|
||||
) as (keyof typeof TweakValuesShouldMatchedTemplate)[];
|
||||
return items.filter((key) => current[key] !== preferred[key]);
|
||||
}
|
||||
|
||||
private _selectNewerTweakSide(current: TweakValues, preferred: Partial<TweakValues>): "REMOTE" | "CURRENT" {
|
||||
Logger(`Modified: ${current.tweakModified} (current) vs ${preferred.tweakModified} (preferred)`);
|
||||
const currentModified = current.tweakModified;
|
||||
const preferredModified = preferred.tweakModified;
|
||||
// debugger;
|
||||
const hasCurrentModified = typeof currentModified === "number" && currentModified > 0;
|
||||
const hasPreferredModified = typeof preferredModified === "number" && preferredModified > 0;
|
||||
|
||||
if (!hasCurrentModified && !hasPreferredModified) return "REMOTE";
|
||||
if (!hasCurrentModified) return "REMOTE";
|
||||
if (!hasPreferredModified) return "CURRENT";
|
||||
if (preferredModified >= currentModified) return "REMOTE";
|
||||
return "CURRENT";
|
||||
}
|
||||
|
||||
private async _shouldAutoAcceptCompatibleLossy(
|
||||
current: TweakValues,
|
||||
preferred: Partial<TweakValues>,
|
||||
mismatchedKeys: (keyof typeof TweakValuesShouldMatchedTemplate)[]
|
||||
): Promise<"REMOTE" | "CURRENT" | undefined> {
|
||||
if (mismatchedKeys.length === 0) return undefined;
|
||||
const hasOnlyCompatibleLossyMismatches = mismatchedKeys.every(
|
||||
(key) => CompatibleButLossyChanges.indexOf(key) !== -1
|
||||
);
|
||||
if (!hasOnlyCompatibleLossyMismatches) return undefined;
|
||||
|
||||
if (this.settings.autoAcceptCompatibleTweak === undefined) {
|
||||
if (this._hasNotifiedAutoAcceptCompatibleUndefined) {
|
||||
return undefined;
|
||||
}
|
||||
this._hasNotifiedAutoAcceptCompatibleUndefined = true;
|
||||
const CHOICE_ENABLE = $msg("TweakMismatchResolve.Action.EnableAutoAcceptCompatible");
|
||||
const CHOICE_DISABLE = $msg("TweakMismatchResolve.Action.DisableAutoAcceptCompatible");
|
||||
const CHOICES = [CHOICE_ENABLE, CHOICE_DISABLE] as const;
|
||||
const message = $msg("TweakMismatchResolve.Message.AutoAcceptCompatibleUndefined");
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(message, CHOICES, {
|
||||
title: $msg("TweakMismatchResolve.Title.AutoAcceptCompatible"),
|
||||
timeout: 0,
|
||||
defaultAction: CHOICE_ENABLE,
|
||||
});
|
||||
if (ret !== CHOICE_ENABLE) {
|
||||
return undefined;
|
||||
}
|
||||
await this.services.setting.applyPartial(
|
||||
{
|
||||
autoAcceptCompatibleTweak: true,
|
||||
},
|
||||
true
|
||||
);
|
||||
Logger("Auto-accept for compatible tweak mismatch has been enabled.");
|
||||
}
|
||||
|
||||
if (this.settings.autoAcceptCompatibleTweak !== true) return undefined;
|
||||
return this._selectNewerTweakSide(current, preferred);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook before saving settings, to check if there are changes in tweak values, and if so,
|
||||
* update the tweakModified timestamp to current time.
|
||||
* This allows other devices to know that the tweak values have been changed and decide whether to accept the new values based on the modification time.
|
||||
* @param next
|
||||
* @param previous
|
||||
* @returns
|
||||
*/
|
||||
async _onBeforeSaveSettingData(next: ObsidianLiveSyncSettings, previous: ObsidianLiveSyncSettings) {
|
||||
const tweakKeys = Object.keys(TweakValuesTemplate) as (keyof TweakValues)[];
|
||||
const tweakKeysForUpdate = tweakKeys.filter((key) => key !== "tweakModified");
|
||||
const hasChangedTweak = tweakKeysForUpdate.some((key) => next[key] !== previous[key]);
|
||||
if (!hasChangedTweak) return;
|
||||
Logger(
|
||||
`Some tweak values have been changed. ${tweakKeysForUpdate.filter((key) => next[key] !== previous[key]).join(", ")}`
|
||||
);
|
||||
const modified = Date.now();
|
||||
Logger(`Modified: ${modified}`);
|
||||
return await Promise.resolve({
|
||||
tweakModified: modified,
|
||||
});
|
||||
}
|
||||
|
||||
async _anyAfterConnectCheckFailed(): Promise<boolean | "CHECKAGAIN" | undefined> {
|
||||
if (!this.core.replicator.tweakSettingsMismatched && !this.core.replicator.preferredTweakValue) return false;
|
||||
const preferred = this.core.replicator.preferredTweakValue;
|
||||
@@ -26,10 +127,16 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
if (ret == "IGNORE") return true;
|
||||
}
|
||||
|
||||
async _checkAndAskResolvingMismatchedTweaks(
|
||||
preferred: Partial<TweakValues>
|
||||
): Promise<[TweakValues | boolean, boolean]> {
|
||||
const mine = extractObject(TweakValuesShouldMatchedTemplate, this.settings);
|
||||
async _checkAndAskResolvingMismatchedTweaks(preferred: TweakValues): Promise<[TweakValues | boolean, boolean]> {
|
||||
const mine = extractObject(TweakValuesTemplate, this.settings) as TweakValues;
|
||||
const mismatchedKeys = this._collectMismatchedTweakKeys(mine, preferred);
|
||||
const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(mine, preferred, mismatchedKeys);
|
||||
if (autoAcceptSide === "REMOTE") {
|
||||
return [{ ...mine, ...preferred }, false];
|
||||
}
|
||||
if (autoAcceptSide === "CURRENT") {
|
||||
return [true, false];
|
||||
}
|
||||
const items = Object.entries(TweakValuesShouldMatchedTemplate);
|
||||
let rebuildRequired = false;
|
||||
let rebuildRecommended = false;
|
||||
@@ -68,8 +175,8 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
tableRows.push(
|
||||
$msg("TweakMismatchResolve.Table.Row", {
|
||||
name: confName(key),
|
||||
self: valueMine,
|
||||
remote: valuePreferred,
|
||||
self: valueToString(valueMine),
|
||||
remote: valueToString(valuePreferred),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -136,9 +243,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
if (!tweaks) {
|
||||
return "IGNORE";
|
||||
}
|
||||
const preferred = extractObject(TweakValuesShouldMatchedTemplate, tweaks);
|
||||
|
||||
const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(preferred);
|
||||
const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(tweaks);
|
||||
if (!conf) return "IGNORE";
|
||||
|
||||
if (conf === true) {
|
||||
@@ -146,10 +251,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
if (rebuildRequired) {
|
||||
await this.core.rebuilder.$rebuildRemote();
|
||||
}
|
||||
Logger(
|
||||
`Tweak values on the remote server have been updated. Your other device will see this message.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
Logger($msg("TweakMismatchResolve.Message.remoteUpdated"), LOG_LEVEL_NOTICE);
|
||||
return "CHECKAGAIN";
|
||||
}
|
||||
if (conf) {
|
||||
@@ -159,7 +261,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
if (rebuildRequired) {
|
||||
await this.core.rebuilder.$fetchLocal();
|
||||
}
|
||||
Logger(`Configuration has been updated as configured by the other device.`, LOG_LEVEL_NOTICE);
|
||||
Logger($msg("TweakMismatchResolve.Message.mineUpdated"), LOG_LEVEL_NOTICE);
|
||||
return "CHECKAGAIN";
|
||||
}
|
||||
return "IGNORE";
|
||||
@@ -186,6 +288,9 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
async _checkAndAskUseRemoteConfiguration(
|
||||
trialSetting: RemoteDBSettings
|
||||
): Promise<{ result: false | TweakValues; requireFetch: boolean }> {
|
||||
if (trialSetting.remoteType === REMOTE_P2P) {
|
||||
return { result: false, requireFetch: false };
|
||||
}
|
||||
const preferred = await this.services.tweakValue.fetchRemotePreferred(trialSetting);
|
||||
if (preferred) {
|
||||
return await this.services.tweakValue.askUseRemoteConfiguration(trialSetting, preferred);
|
||||
@@ -197,6 +302,16 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
trialSetting: RemoteDBSettings,
|
||||
preferred: TweakValues
|
||||
): Promise<{ result: false | TweakValues; requireFetch: boolean }> {
|
||||
const localTweaks = extractObject(TweakValuesTemplate, this.settings) as TweakValues;
|
||||
const mismatchedKeys = this._collectMismatchedTweakKeys(localTweaks, preferred);
|
||||
const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(localTweaks, preferred, mismatchedKeys);
|
||||
if (autoAcceptSide === "REMOTE") {
|
||||
return { result: { ...trialSetting, ...preferred }, requireFetch: false };
|
||||
}
|
||||
if (autoAcceptSide === "CURRENT") {
|
||||
return { result: false, requireFetch: false };
|
||||
}
|
||||
|
||||
const items = Object.entries(TweakValuesShouldMatchedTemplate);
|
||||
let rebuildRequired = false;
|
||||
let rebuildRecommended = false;
|
||||
@@ -207,8 +322,8 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
// const items = [mine,preferred]
|
||||
for (const v of items) {
|
||||
const key = v[0] as keyof typeof TweakValuesShouldMatchedTemplate;
|
||||
const remoteValueForDisplay = escapeMarkdownValue(preferred[key]);
|
||||
const currentValueForDisplay = `${escapeMarkdownValue((trialSetting as TweakValues)?.[key])}`;
|
||||
const remoteValueForDisplay = escapeMarkdownValue(valueToString(preferred[key]));
|
||||
const currentValueForDisplay = escapeMarkdownValue(valueToString((trialSetting as TweakValues)?.[key]));
|
||||
if ((trialSetting as TweakValues)?.[key] !== preferred[key]) {
|
||||
if (IncompatibleChanges.indexOf(key) !== -1) {
|
||||
rebuildRequired = true;
|
||||
@@ -285,6 +400,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
}
|
||||
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
services.setting.onBeforeSaveSettingData.addHandler(this._onBeforeSaveSettingData.bind(this));
|
||||
services.tweakValue.fetchRemotePreferred.setHandler(this._fetchRemotePreferredTweakValues.bind(this));
|
||||
services.tweakValue.checkAndAskResolvingMismatched.setHandler(
|
||||
this._checkAndAskResolvingMismatchedTweaks.bind(this)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, type RemoteDBSettings, type TweakValues } from "@lib/common/types";
|
||||
import { ModuleResolvingMismatchedTweaks } from "./ModuleResolveMismatchedTweaks";
|
||||
|
||||
function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
const askSelectStringDialogue = vi.fn(async () => undefined);
|
||||
const core = {
|
||||
_services: {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
setting: {
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
...settingsOverride,
|
||||
},
|
||||
confirm: {
|
||||
askSelectStringDialogue,
|
||||
},
|
||||
} as any;
|
||||
|
||||
Object.defineProperty(core, "services", {
|
||||
get() {
|
||||
return core._services;
|
||||
},
|
||||
});
|
||||
|
||||
const module = new ModuleResolvingMismatchedTweaks(core);
|
||||
return { module, core, askSelectStringDialogue };
|
||||
}
|
||||
|
||||
describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
it("should auto-accept compatible mismatches on connect check using newer remote tweakModified", async () => {
|
||||
const { module, askSelectStringDialogue } = createModule({
|
||||
autoAcceptCompatibleTweak: true,
|
||||
hashAlg: "xxhash64",
|
||||
tweakModified: 100,
|
||||
});
|
||||
|
||||
const preferred = {
|
||||
...(DEFAULT_SETTINGS as unknown as TweakValues),
|
||||
hashAlg: "xxhash32",
|
||||
tweakModified: 200,
|
||||
} as Partial<TweakValues>;
|
||||
|
||||
const [conf, rebuild] = await module._checkAndAskResolvingMismatchedTweaks(preferred);
|
||||
|
||||
expect(conf).toEqual(preferred);
|
||||
expect(rebuild).toBe(false);
|
||||
expect(askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fallback to manual confirmation when mismatches are mixed on connect check", async () => {
|
||||
const { module, askSelectStringDialogue } = createModule({
|
||||
autoAcceptCompatibleTweak: true,
|
||||
hashAlg: "xxhash64",
|
||||
encrypt: false,
|
||||
tweakModified: 100,
|
||||
});
|
||||
|
||||
const preferred = {
|
||||
...(DEFAULT_SETTINGS as unknown as TweakValues),
|
||||
hashAlg: "xxhash32",
|
||||
encrypt: true,
|
||||
tweakModified: 200,
|
||||
} as Partial<TweakValues>;
|
||||
|
||||
const [conf, rebuild] = await module._checkAndAskResolvingMismatchedTweaks(preferred);
|
||||
|
||||
expect(conf).toBe(false);
|
||||
expect(rebuild).toBe(false);
|
||||
expect(askSelectStringDialogue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should auto-accept compatible mismatches on remote-config check using newer local tweakModified", async () => {
|
||||
const { module, askSelectStringDialogue } = createModule({
|
||||
autoAcceptCompatibleTweak: true,
|
||||
hashAlg: "xxhash64",
|
||||
tweakModified: 300,
|
||||
});
|
||||
|
||||
const trialSetting = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
hashAlg: "xxhash64",
|
||||
tweakModified: 300,
|
||||
} as RemoteDBSettings;
|
||||
|
||||
const preferred = {
|
||||
...(trialSetting as unknown as TweakValues),
|
||||
hashAlg: "xxhash32",
|
||||
tweakModified: 200,
|
||||
} as TweakValues;
|
||||
|
||||
const result = await module._askUseRemoteConfiguration(trialSetting, preferred);
|
||||
|
||||
expect(result).toEqual({ result: false, requireFetch: false });
|
||||
expect(askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,429 +0,0 @@
|
||||
import { unique } from "octagonal-wheels/collection";
|
||||
import { throttle } from "octagonal-wheels/function";
|
||||
import { EVENT_ON_UNRESOLVED_ERROR, eventHub } from "../../common/events.ts";
|
||||
import { BASE_IS_NEW, EVEN, isValidPath, TARGET_IS_NEW } from "../../common/utils.ts";
|
||||
import {
|
||||
type FilePathWithPrefixLC,
|
||||
type FilePathWithPrefix,
|
||||
type MetaEntry,
|
||||
isMetaEntry,
|
||||
type EntryDoc,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_DEBUG,
|
||||
type UXFileInfoStub,
|
||||
type LOG_LEVEL,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { isAnyNote } from "../../lib/src/common/utils.ts";
|
||||
import { stripAllPrefixes } from "../../lib/src/string_and_binary/path.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import { withConcurrency } from "octagonal-wheels/iterable/map";
|
||||
import type { InjectableServiceHub } from "../../lib/src/services/InjectableServices.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
export class ModuleInitializerFile extends AbstractModule {
|
||||
private _detectedErrors = new Set<string>();
|
||||
|
||||
private logDetectedError(message: string, logLevel: LOG_LEVEL = LOG_LEVEL_INFO, key?: string) {
|
||||
this._detectedErrors.add(message);
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
this._log(message, logLevel, key);
|
||||
}
|
||||
private resetDetectedError(message: string) {
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
this._detectedErrors.delete(message);
|
||||
}
|
||||
private async _performFullScan(showingNotice?: boolean, ignoreSuspending: boolean = false): Promise<boolean> {
|
||||
this._log("Opening the key-value database", LOG_LEVEL_VERBOSE);
|
||||
const isInitialized = (await this.core.kvDB.get<boolean>("initialized")) || false;
|
||||
// synchronize all files between database and storage.
|
||||
|
||||
const ERR_NOT_CONFIGURED =
|
||||
"LiveSync is not configured yet. Synchronising between the storage and the local database is now prevented.";
|
||||
if (!this.settings.isConfigured) {
|
||||
this.logDetectedError(ERR_NOT_CONFIGURED, showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO, "syncAll");
|
||||
return false;
|
||||
}
|
||||
this.resetDetectedError(ERR_NOT_CONFIGURED);
|
||||
|
||||
const ERR_SUSPENDING =
|
||||
"Now suspending file watching. Synchronising between the storage and the local database is now prevented.";
|
||||
if (!ignoreSuspending && this.settings.suspendFileWatching) {
|
||||
this.logDetectedError(ERR_SUSPENDING, showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO, "syncAll");
|
||||
return false;
|
||||
}
|
||||
const MSG_IN_REMEDIATION = `Started in remediation Mode! (Max mtime for reflect events is set). Synchronising between the storage and the local database is now prevented.`;
|
||||
this.resetDetectedError(ERR_SUSPENDING);
|
||||
if (this.settings.maxMTimeForReflectEvents > 0) {
|
||||
this.logDetectedError(MSG_IN_REMEDIATION, LOG_LEVEL_NOTICE, "syncAll");
|
||||
return false;
|
||||
}
|
||||
this.resetDetectedError(MSG_IN_REMEDIATION);
|
||||
|
||||
if (showingNotice) {
|
||||
this._log("Initializing", LOG_LEVEL_NOTICE, "syncAll");
|
||||
}
|
||||
if (isInitialized) {
|
||||
this._log("Restoring storage state", LOG_LEVEL_VERBOSE);
|
||||
await this.core.storageAccess.restoreState();
|
||||
}
|
||||
|
||||
this._log("Initialize and checking database files");
|
||||
this._log("Checking deleted files");
|
||||
await this.collectDeletedFiles();
|
||||
|
||||
this._log("Collecting local files on the storage", LOG_LEVEL_VERBOSE);
|
||||
const filesStorageSrc = await this.core.storageAccess.getFiles();
|
||||
|
||||
const _filesStorage = [] as typeof filesStorageSrc;
|
||||
|
||||
for (const f of filesStorageSrc) {
|
||||
if (await this.services.vault.isTargetFile(f.path)) {
|
||||
_filesStorage.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
const convertCase = <FilePathWithPrefix>(path: FilePathWithPrefix): FilePathWithPrefixLC => {
|
||||
if (this.settings.handleFilenameCaseSensitive) {
|
||||
return path as FilePathWithPrefixLC;
|
||||
}
|
||||
return (path as string).toLowerCase() as FilePathWithPrefixLC;
|
||||
};
|
||||
|
||||
// If handleFilenameCaseSensitive is enabled, `FilePathWithPrefixLC` is the same as `FilePathWithPrefix`.
|
||||
|
||||
const storageFileNameMap = Object.fromEntries(
|
||||
_filesStorage.map((e) => [e.path, e] as [FilePathWithPrefix, UXFileInfoStub])
|
||||
);
|
||||
|
||||
const storageFileNames = Object.keys(storageFileNameMap) as FilePathWithPrefix[];
|
||||
|
||||
const storageFileNameCapsPair = storageFileNames.map(
|
||||
(e) => [e, convertCase(e)] as [FilePathWithPrefix, FilePathWithPrefixLC]
|
||||
);
|
||||
|
||||
// const storageFileNameCS2CI = Object.fromEntries(storageFileNameCapsPair) as Record<FilePathWithPrefix, FilePathWithPrefixLC>;
|
||||
const storageFileNameCI2CS = Object.fromEntries(storageFileNameCapsPair.map((e) => [e[1], e[0]])) as Record<
|
||||
FilePathWithPrefixLC,
|
||||
FilePathWithPrefix
|
||||
>;
|
||||
|
||||
this._log("Collecting local files on the DB", LOG_LEVEL_VERBOSE);
|
||||
const _DBEntries = [] as MetaEntry[];
|
||||
let count = 0;
|
||||
// Fetch all documents from the database (including conflicts to prevent overwriting).
|
||||
for await (const doc of this.localDatabase.findAllNormalDocs({ conflicts: true })) {
|
||||
count++;
|
||||
if (count % 25 == 0)
|
||||
this._log(
|
||||
`Collecting local files on the DB: ${count}`,
|
||||
showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO,
|
||||
"syncAll"
|
||||
);
|
||||
const path = this.getPath(doc);
|
||||
|
||||
if (isValidPath(path) && (await this.services.vault.isTargetFile(path))) {
|
||||
if (!isMetaEntry(doc)) {
|
||||
this._log(`Invalid entry: ${path}`, LOG_LEVEL_INFO);
|
||||
continue;
|
||||
}
|
||||
_DBEntries.push(doc);
|
||||
}
|
||||
}
|
||||
|
||||
const databaseFileNameMap = Object.fromEntries(
|
||||
_DBEntries.map((e) => [this.getPath(e), e] as [FilePathWithPrefix, MetaEntry])
|
||||
);
|
||||
const databaseFileNames = Object.keys(databaseFileNameMap) as FilePathWithPrefix[];
|
||||
const databaseFileNameCapsPair = databaseFileNames.map(
|
||||
(e) => [e, convertCase(e)] as [FilePathWithPrefix, FilePathWithPrefixLC]
|
||||
);
|
||||
// const databaseFileNameCS2CI = Object.fromEntries(databaseFileNameCapsPair) as Record<FilePathWithPrefix, FilePathWithPrefixLC>;
|
||||
const databaseFileNameCI2CS = Object.fromEntries(databaseFileNameCapsPair.map((e) => [e[1], e[0]])) as Record<
|
||||
FilePathWithPrefix,
|
||||
FilePathWithPrefixLC
|
||||
>;
|
||||
|
||||
const allFiles = unique([
|
||||
...Object.keys(databaseFileNameCI2CS),
|
||||
...Object.keys(storageFileNameCI2CS),
|
||||
]) as FilePathWithPrefixLC[];
|
||||
|
||||
this._log(`Total files in the database: ${databaseFileNames.length}`, LOG_LEVEL_VERBOSE, "syncAll");
|
||||
this._log(`Total files in the storage: ${storageFileNames.length}`, LOG_LEVEL_VERBOSE, "syncAll");
|
||||
this._log(`Total files: ${allFiles.length}`, LOG_LEVEL_VERBOSE, "syncAll");
|
||||
const filesExistOnlyInStorage = allFiles.filter((e) => !databaseFileNameCI2CS[e]);
|
||||
const filesExistOnlyInDatabase = allFiles.filter((e) => !storageFileNameCI2CS[e]);
|
||||
const filesExistBoth = allFiles.filter((e) => databaseFileNameCI2CS[e] && storageFileNameCI2CS[e]);
|
||||
|
||||
this._log(`Files exist only in storage: ${filesExistOnlyInStorage.length}`, LOG_LEVEL_VERBOSE, "syncAll");
|
||||
this._log(`Files exist only in database: ${filesExistOnlyInDatabase.length}`, LOG_LEVEL_VERBOSE, "syncAll");
|
||||
this._log(`Files exist both in storage and database: ${filesExistBoth.length}`, LOG_LEVEL_VERBOSE, "syncAll");
|
||||
|
||||
this._log("Synchronising...");
|
||||
const processStatus = {} as Record<string, string>;
|
||||
const logLevel = showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||
const updateLog = throttle((key: string, msg: string) => {
|
||||
processStatus[key] = msg;
|
||||
const log = Object.values(processStatus).join("\n");
|
||||
this._log(log, logLevel, "syncAll");
|
||||
}, 25);
|
||||
|
||||
const initProcess = [];
|
||||
const runAll = async <T>(procedureName: string, objects: T[], callback: (arg: T) => Promise<void>) => {
|
||||
if (objects.length == 0) {
|
||||
this._log(`${procedureName}: Nothing to do`);
|
||||
return;
|
||||
}
|
||||
this._log(procedureName);
|
||||
if (!this.localDatabase.isReady) throw Error("Database is not ready!");
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
let total = 0;
|
||||
for await (const result of withConcurrency(
|
||||
objects,
|
||||
async (e) => {
|
||||
try {
|
||||
await callback(e);
|
||||
return true;
|
||||
} catch (ex) {
|
||||
this._log(`Error while ${procedureName}`, LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
10
|
||||
)) {
|
||||
if (result) {
|
||||
success++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
total++;
|
||||
const msg = `${procedureName}: DONE:${success}, FAILED:${failed}, LAST:${objects.length - total}`;
|
||||
updateLog(procedureName, msg);
|
||||
}
|
||||
const msg = `${procedureName} All done: DONE:${success}, FAILED:${failed}`;
|
||||
updateLog(procedureName, msg);
|
||||
};
|
||||
initProcess.push(
|
||||
runAll("UPDATE DATABASE", filesExistOnlyInStorage, async (e) => {
|
||||
// Exists in storage but not in database.
|
||||
const file = storageFileNameMap[storageFileNameCI2CS[e]];
|
||||
if (!this.services.vault.isFileSizeTooLarge(file.stat.size)) {
|
||||
const path = file.path;
|
||||
await this.core.fileHandler.storeFileToDB(file);
|
||||
// fireAndForget(() => this.checkAndApplySettingFromMarkdown(path, true));
|
||||
eventHub.emitEvent("event-file-changed", { file: path, automated: true });
|
||||
} else {
|
||||
this._log(`UPDATE DATABASE: ${e} has been skipped due to file size exceeding the limit`, logLevel);
|
||||
}
|
||||
})
|
||||
);
|
||||
initProcess.push(
|
||||
runAll("UPDATE STORAGE", filesExistOnlyInDatabase, async (e) => {
|
||||
const w = databaseFileNameMap[databaseFileNameCI2CS[e]];
|
||||
// Exists in database but not in storage.
|
||||
const path = this.getPath(w) ?? e;
|
||||
if (w && !(w.deleted || w._deleted)) {
|
||||
if (!this.services.vault.isFileSizeTooLarge(w.size)) {
|
||||
// Prevent applying the conflicted state to the storage.
|
||||
if (w._conflicts?.length ?? 0 > 0) {
|
||||
this._log(`UPDATE STORAGE: ${path} has conflicts. skipped (x)`, LOG_LEVEL_INFO);
|
||||
return;
|
||||
}
|
||||
// await this.pullFile(path, undefined, false, undefined, false);
|
||||
// Memo: No need to force
|
||||
await this.core.fileHandler.dbToStorage(path, null, true);
|
||||
// fireAndForget(() => this.checkAndApplySettingFromMarkdown(e, true));
|
||||
eventHub.emitEvent("event-file-changed", {
|
||||
file: e,
|
||||
automated: true,
|
||||
});
|
||||
this._log(`Check or pull from db:${path} OK`);
|
||||
} else {
|
||||
this._log(
|
||||
`UPDATE STORAGE: ${path} has been skipped due to file size exceeding the limit`,
|
||||
logLevel
|
||||
);
|
||||
}
|
||||
} else if (w) {
|
||||
this._log(`Deletion history skipped: ${path}`, LOG_LEVEL_VERBOSE);
|
||||
} else {
|
||||
this._log(`entry not found: ${path}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const fileMap = filesExistBoth.map((path) => {
|
||||
const file = storageFileNameMap[storageFileNameCI2CS[path]];
|
||||
const doc = databaseFileNameMap[databaseFileNameCI2CS[path]];
|
||||
return { file, doc };
|
||||
});
|
||||
initProcess.push(
|
||||
runAll("SYNC DATABASE AND STORAGE", fileMap, async (e) => {
|
||||
const { file, doc } = e;
|
||||
// Prevent applying the conflicted state to the storage.
|
||||
if (doc._conflicts?.length ?? 0 > 0) {
|
||||
this._log(`SYNC DATABASE AND STORAGE: ${file.path} has conflicts. skipped`, LOG_LEVEL_INFO);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!this.services.vault.isFileSizeTooLarge(file.stat.size) &&
|
||||
!this.services.vault.isFileSizeTooLarge(doc.size)
|
||||
) {
|
||||
await this.syncFileBetweenDBandStorage(file, doc);
|
||||
} else {
|
||||
this._log(
|
||||
`SYNC DATABASE AND STORAGE: ${this.getPath(doc)} has been skipped due to file size exceeding the limit`,
|
||||
logLevel
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(initProcess);
|
||||
|
||||
// this.setStatusBarText(`NOW TRACKING!`);
|
||||
this._log("Initialized, NOW TRACKING!");
|
||||
if (!isInitialized) {
|
||||
await this.core.kvDB.set("initialized", true);
|
||||
}
|
||||
if (showingNotice) {
|
||||
this._log("Initialize done!", LOG_LEVEL_NOTICE, "syncAll");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async syncFileBetweenDBandStorage(file: UXFileInfoStub, doc: MetaEntry) {
|
||||
if (!doc) {
|
||||
throw new Error(`Missing doc:${(file as any).path}`);
|
||||
}
|
||||
if ("path" in file) {
|
||||
const w = await this.core.storageAccess.getFileStub((file as any).path);
|
||||
if (w) {
|
||||
file = w;
|
||||
} else {
|
||||
throw new Error(`Missing file:${(file as any).path}`);
|
||||
}
|
||||
}
|
||||
|
||||
const compareResult = this.services.path.compareFileFreshness(file, doc);
|
||||
switch (compareResult) {
|
||||
case BASE_IS_NEW:
|
||||
if (!this.services.vault.isFileSizeTooLarge(file.stat.size)) {
|
||||
this._log("STORAGE -> DB :" + file.path);
|
||||
await this.core.fileHandler.storeFileToDB(file);
|
||||
} else {
|
||||
this._log(
|
||||
`STORAGE -> DB : ${file.path} has been skipped due to file size exceeding the limit`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
break;
|
||||
case TARGET_IS_NEW:
|
||||
if (!this.services.vault.isFileSizeTooLarge(doc.size)) {
|
||||
this._log("STORAGE <- DB :" + file.path);
|
||||
if (await this.core.fileHandler.dbToStorage(doc, stripAllPrefixes(file.path), true)) {
|
||||
eventHub.emitEvent("event-file-changed", {
|
||||
file: file.path,
|
||||
automated: true,
|
||||
});
|
||||
} else {
|
||||
this._log(`STORAGE <- DB : Cloud not read ${file.path}, possibly deleted`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return caches;
|
||||
} else {
|
||||
this._log(
|
||||
`STORAGE <- DB : ${file.path} has been skipped due to file size exceeding the limit`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
break;
|
||||
case EVEN:
|
||||
this._log("STORAGE == DB :" + file.path + "", LOG_LEVEL_DEBUG);
|
||||
break;
|
||||
default:
|
||||
this._log("STORAGE ?? DB :" + file.path + " Something got weird");
|
||||
}
|
||||
}
|
||||
|
||||
// This method uses an old version of database accessor, which is not recommended.
|
||||
// TODO: Fix
|
||||
async collectDeletedFiles() {
|
||||
const limitDays = this.settings.automaticallyDeleteMetadataOfDeletedFiles;
|
||||
if (limitDays <= 0) return;
|
||||
this._log(`Checking expired file history`);
|
||||
const limit = Date.now() - 86400 * 1000 * limitDays;
|
||||
const notes: {
|
||||
path: string;
|
||||
mtime: number;
|
||||
ttl: number;
|
||||
doc: PouchDB.Core.ExistingDocument<EntryDoc & PouchDB.Core.AllDocsMeta>;
|
||||
}[] = [];
|
||||
for await (const doc of this.localDatabase.findAllDocs({ conflicts: true })) {
|
||||
if (isAnyNote(doc)) {
|
||||
if (doc.deleted && doc.mtime - limit < 0) {
|
||||
notes.push({
|
||||
path: this.getPath(doc),
|
||||
mtime: doc.mtime,
|
||||
ttl: (doc.mtime - limit) / 1000 / 86400,
|
||||
doc: doc,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (notes.length == 0) {
|
||||
this._log("There are no old documents");
|
||||
this._log(`Checking expired file history done`);
|
||||
return;
|
||||
}
|
||||
for (const v of notes) {
|
||||
this._log(`Deletion history expired: ${v.path}`);
|
||||
const delDoc = v.doc;
|
||||
delDoc._deleted = true;
|
||||
await this.localDatabase.putRaw(delDoc);
|
||||
}
|
||||
this._log(`Checking expired file history done`);
|
||||
}
|
||||
|
||||
private async _initializeDatabase(
|
||||
showingNotice: boolean = false,
|
||||
reopenDatabase = true,
|
||||
ignoreSuspending: boolean = false
|
||||
): Promise<boolean> {
|
||||
this.services.appLifecycle.resetIsReady();
|
||||
if (
|
||||
!reopenDatabase ||
|
||||
(await this.services.database.openDatabase({
|
||||
databaseEvents: this.services.databaseEvents,
|
||||
replicator: this.services.replicator,
|
||||
}))
|
||||
) {
|
||||
if (this.localDatabase.isReady) {
|
||||
await this.services.vault.scanVault(showingNotice, ignoreSuspending);
|
||||
}
|
||||
const ERR_INITIALISATION_FAILED = `Initializing database has been failed on some module!`;
|
||||
if (!(await this.services.databaseEvents.onDatabaseInitialised(showingNotice))) {
|
||||
this.logDetectedError(ERR_INITIALISATION_FAILED, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
this.resetDetectedError(ERR_INITIALISATION_FAILED);
|
||||
this.services.appLifecycle.markIsReady();
|
||||
// run queued event once.
|
||||
await this.services.fileProcessing.commitPendingFileEvents();
|
||||
return true;
|
||||
} else {
|
||||
this.services.appLifecycle.resetIsReady();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
private _reportDetectedErrors(): Promise<string[]> {
|
||||
return Promise.resolve(Array.from(this._detectedErrors));
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
services.appLifecycle.getUnresolvedMessages.addHandler(this._reportDetectedErrors.bind(this));
|
||||
services.databaseEvents.initialiseDatabase.addHandler(this._initializeDatabase.bind(this));
|
||||
services.vault.scanVault.addHandler(this._performFullScan.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { sizeToHumanReadable } from "octagonal-wheels/number";
|
||||
import { $msg } from "src/lib/src/common/i18n.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
import { EVENT_REQUEST_CHECK_REMOTE_SIZE, eventHub } from "@/common/events.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
|
||||
export class ModuleCheckRemoteSize extends AbstractModule {
|
||||
checkRemoteSize(): Promise<boolean> {
|
||||
this.settings.notifyThresholdOfRemoteStorageSize = 1;
|
||||
return this._allScanStat();
|
||||
}
|
||||
|
||||
private async _allScanStat(): Promise<boolean> {
|
||||
if (this.services.API.isOnline === false) {
|
||||
this._log("Network is offline, skipping remote size check.", LOG_LEVEL_INFO);
|
||||
return true;
|
||||
}
|
||||
this._log($msg("moduleCheckRemoteSize.logCheckingStorageSizes"), LOG_LEVEL_VERBOSE);
|
||||
if (this.settings.notifyThresholdOfRemoteStorageSize < 0) {
|
||||
const message = $msg("moduleCheckRemoteSize.msgSetDBCapacity");
|
||||
const ANSWER_0 = $msg("moduleCheckRemoteSize.optionNoWarn");
|
||||
const ANSWER_800 = $msg("moduleCheckRemoteSize.option800MB");
|
||||
const ANSWER_2000 = $msg("moduleCheckRemoteSize.option2GB");
|
||||
const ASK_ME_NEXT_TIME = $msg("moduleCheckRemoteSize.optionAskMeLater");
|
||||
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(
|
||||
message,
|
||||
[ANSWER_0, ANSWER_800, ANSWER_2000, ASK_ME_NEXT_TIME],
|
||||
{
|
||||
defaultAction: ASK_ME_NEXT_TIME,
|
||||
title: $msg("moduleCheckRemoteSize.titleDatabaseSizeNotify"),
|
||||
timeout: 40,
|
||||
}
|
||||
);
|
||||
if (ret == ANSWER_0) {
|
||||
this.settings.notifyThresholdOfRemoteStorageSize = 0;
|
||||
await this.saveSettings();
|
||||
} else if (ret == ANSWER_800) {
|
||||
this.settings.notifyThresholdOfRemoteStorageSize = 800;
|
||||
await this.saveSettings();
|
||||
} else if (ret == ANSWER_2000) {
|
||||
this.settings.notifyThresholdOfRemoteStorageSize = 2000;
|
||||
await this.saveSettings();
|
||||
}
|
||||
}
|
||||
if (this.settings.notifyThresholdOfRemoteStorageSize > 0) {
|
||||
const remoteStat = await this.core.replicator?.getRemoteStatus(this.settings);
|
||||
if (remoteStat) {
|
||||
const estimatedSize = remoteStat.estimatedSize;
|
||||
if (estimatedSize) {
|
||||
const maxSize = this.settings.notifyThresholdOfRemoteStorageSize * 1024 * 1024;
|
||||
if (estimatedSize > maxSize) {
|
||||
const message = $msg("moduleCheckRemoteSize.msgDatabaseGrowing", {
|
||||
estimatedSize: sizeToHumanReadable(estimatedSize),
|
||||
maxSize: sizeToHumanReadable(maxSize),
|
||||
});
|
||||
const newMax = ~~(estimatedSize / 1024 / 1024) + 100;
|
||||
const ANSWER_ENLARGE_LIMIT = $msg("moduleCheckRemoteSize.optionIncreaseLimit", {
|
||||
newMax: newMax.toString(),
|
||||
});
|
||||
const ANSWER_REBUILD = $msg("moduleCheckRemoteSize.optionRebuildAll");
|
||||
const ANSWER_IGNORE = $msg("moduleCheckRemoteSize.optionDismiss");
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(
|
||||
message,
|
||||
[ANSWER_ENLARGE_LIMIT, ANSWER_REBUILD, ANSWER_IGNORE],
|
||||
{
|
||||
defaultAction: ANSWER_IGNORE,
|
||||
title: $msg("moduleCheckRemoteSize.titleDatabaseSizeLimitExceeded"),
|
||||
timeout: 60,
|
||||
}
|
||||
);
|
||||
if (ret == ANSWER_REBUILD) {
|
||||
const ret = await this.core.confirm.askYesNoDialog(
|
||||
$msg("moduleCheckRemoteSize.msgConfirmRebuild"),
|
||||
{ defaultOption: "No" }
|
||||
);
|
||||
if (ret == "yes") {
|
||||
this.core.settings.notifyThresholdOfRemoteStorageSize = -1;
|
||||
await this.saveSettings();
|
||||
await this.core.rebuilder.scheduleRebuild();
|
||||
}
|
||||
} else if (ret == ANSWER_ENLARGE_LIMIT) {
|
||||
this.settings.notifyThresholdOfRemoteStorageSize = ~~(estimatedSize / 1024 / 1024) + 100;
|
||||
this._log(
|
||||
$msg("moduleCheckRemoteSize.logThresholdEnlarged", {
|
||||
size: this.settings.notifyThresholdOfRemoteStorageSize.toString(),
|
||||
}),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
// await this.core.saveSettings();
|
||||
await this.core.services.setting.saveSettingData();
|
||||
} else {
|
||||
// Dismiss or Close the dialog
|
||||
}
|
||||
|
||||
this._log(
|
||||
$msg("moduleCheckRemoteSize.logExceededWarning", {
|
||||
measuredSize: sizeToHumanReadable(estimatedSize),
|
||||
notifySize: sizeToHumanReadable(
|
||||
this.settings.notifyThresholdOfRemoteStorageSize * 1024 * 1024
|
||||
),
|
||||
}),
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
} else {
|
||||
this._log(
|
||||
$msg("moduleCheckRemoteSize.logCurrentStorageSize", {
|
||||
measuredSize: sizeToHumanReadable(estimatedSize),
|
||||
}),
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _everyOnloadStart(): Promise<boolean> {
|
||||
this.addCommand({
|
||||
id: "livesync-reset-remote-size-threshold-and-check",
|
||||
name: "Reset notification threshold and check the remote database usage",
|
||||
callback: async () => {
|
||||
await this.checkRemoteSize();
|
||||
},
|
||||
});
|
||||
eventHub.onEvent(EVENT_REQUEST_CHECK_REMOTE_SIZE, () => this.checkRemoteSize());
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onScanningStartupIssues.addHandler(this._allScanStat.bind(this));
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,7 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
return;
|
||||
}
|
||||
|
||||
const isHidden = document.hidden;
|
||||
const isHidden = activeWindow.document.hidden;
|
||||
if (this.isLastHidden === isHidden) {
|
||||
return;
|
||||
}
|
||||
@@ -134,7 +134,7 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
} else {
|
||||
// suspend all temporary.
|
||||
if (this.services.appLifecycle.isSuspended()) return;
|
||||
if (!this.hasFocus) return;
|
||||
// Do not block resume by focus state here; visibility recovery should be enough.
|
||||
await this.services.appLifecycle.onResuming();
|
||||
await this.services.appLifecycle.onResumed();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TFile, Modal, App, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "../../../deps.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 {
|
||||
type DocumentID,
|
||||
@@ -96,7 +96,7 @@ export class DocumentHistoryModal extends Modal {
|
||||
if (!file && id) {
|
||||
this.file = this.services.path.id2path(id);
|
||||
}
|
||||
if (localStorage.getItem("ols-history-highlightdiff") == "1") {
|
||||
if (this.app.loadLocalStorage("ols-history-highlightdiff") == "1") {
|
||||
this.showDiff = true;
|
||||
}
|
||||
}
|
||||
@@ -153,22 +153,87 @@ export class DocumentHistoryModal extends Modal {
|
||||
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.appendSearchHighlightedText(this.contentView.createSpan({ cls: "history-deleted" }), text);
|
||||
} else if (operation == DIFF_EQUAL) {
|
||||
this.appendSearchHighlightedText(this.contentView.createSpan({ cls: "history-normal" }), text);
|
||||
} else if (operation == DIFF_INSERT) {
|
||||
this.appendSearchHighlightedText(this.contentView.createSpan({ cls: "history-added" }), text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appendSearchHighlightedText(container: HTMLElement, text: string) {
|
||||
if (!this.searchKeyword) {
|
||||
container.appendText(text);
|
||||
return;
|
||||
}
|
||||
const escapedKeyword = this.searchKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(escapedKeyword, "gi");
|
||||
let lastIndex = 0;
|
||||
for (const match of text.matchAll(regex)) {
|
||||
const index = match.index ?? 0;
|
||||
if (index > lastIndex) {
|
||||
container.appendText(text.slice(lastIndex, index));
|
||||
}
|
||||
container.createEl("mark", { text: match[0] });
|
||||
lastIndex = index + match[0].length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
container.appendText(text.slice(lastIndex));
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
const db = this.core.localDatabase;
|
||||
const w = await db.getDBEntry(this.file, { rev: rev }, false, false, true);
|
||||
this.currentText = "";
|
||||
this.currentDeleted = false;
|
||||
this.prepareContentView();
|
||||
if (w === false) {
|
||||
this.currentDeleted = true;
|
||||
this.info.innerHTML = "";
|
||||
this.contentView.innerHTML = `Could not read this revision<br>(${rev})`;
|
||||
this.info.empty();
|
||||
this.contentView.appendText("Could not read this revision");
|
||||
this.contentView.createEl("br");
|
||||
this.contentView.appendText(`(${rev})`);
|
||||
} else {
|
||||
this.currentDoc = w;
|
||||
this.info.innerHTML = `Modified:${new Date(w.mtime).toLocaleString()}`;
|
||||
let result = undefined;
|
||||
this.info.setText(`Modified:${new Date(w.mtime).toLocaleString()}`);
|
||||
const w1data = readDocument(w);
|
||||
this.currentDeleted = !!w.deleted;
|
||||
// this.currentText = w1data;
|
||||
if (typeof w1data == "string") {
|
||||
this.currentText = w1data;
|
||||
}
|
||||
let rendered = false;
|
||||
if (this.showDiff) {
|
||||
const prevRevIdx = this.revs_info.length - 1 - ((this.range.value as any) / 1 - 1);
|
||||
if (prevRevIdx >= 0 && prevRevIdx < this.revs_info.length) {
|
||||
@@ -176,72 +241,55 @@ export class DocumentHistoryModal extends Modal {
|
||||
const w2 = await db.getDBEntry(this.file, { rev: oldRev }, false, false, true);
|
||||
if (w2 != false) {
|
||||
if (typeof w1data == "string") {
|
||||
result = "";
|
||||
const dmp = new diff_match_patch();
|
||||
const w2data = readDocument(w2) as string;
|
||||
const diff = dmp.diff_main(w2data, w1data);
|
||||
dmp.diff_cleanupSemantic(diff);
|
||||
for (const v of diff) {
|
||||
const x1 = v[0];
|
||||
const x2 = v[1];
|
||||
let text = escapeStringToHTML(x2);
|
||||
if (this.searchKeyword) {
|
||||
const regex = new RegExp(
|
||||
`(${this.searchKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`,
|
||||
"gi"
|
||||
);
|
||||
text = text.replace(regex, "<mark>$1</mark>");
|
||||
}
|
||||
if (x1 == DIFF_DELETE) {
|
||||
result += "<span class='history-deleted'>" + text + "</span>";
|
||||
} else if (x1 == DIFF_EQUAL) {
|
||||
result += "<span class='history-normal'>" + text + "</span>";
|
||||
} else if (x1 == DIFF_INSERT) {
|
||||
result += "<span class='history-added'>" + text + "</span>";
|
||||
const w2data = readDocument(w2);
|
||||
if (typeof w2data == "string") {
|
||||
const dmp = new diff_match_patch();
|
||||
const diff = dmp.diff_main(w2data, w1data);
|
||||
dmp.diff_cleanupSemantic(diff);
|
||||
if (this.currentDeleted) {
|
||||
this.appendDeletedNotice();
|
||||
}
|
||||
this.appendTextDiff(diff);
|
||||
rendered = true;
|
||||
}
|
||||
result = result.replace(/\n/g, "<br>");
|
||||
} else if (isImage(this.file)) {
|
||||
const src = this.generateBlobURL("base", w1data);
|
||||
const overlay = this.generateBlobURL(
|
||||
"overlay",
|
||||
readDocument(w2) as Uint8Array<ArrayBuffer>
|
||||
);
|
||||
result = `<div class='ls-imgdiff-wrap'>
|
||||
<div class='overlay'>
|
||||
<img class='img-base' src="${src}">
|
||||
<img class='img-overlay' src='${overlay}'>
|
||||
</div>
|
||||
</div>`;
|
||||
this.contentView.removeClass("op-pre");
|
||||
this.prepareContentView(false);
|
||||
if (this.currentDeleted) {
|
||||
this.appendDeletedNotice(false);
|
||||
}
|
||||
this.appendImageDiff(src, overlay);
|
||||
rendered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result == undefined) {
|
||||
if (!rendered) {
|
||||
if (typeof w1data != "string") {
|
||||
if (isImage(this.file)) {
|
||||
const src = this.generateBlobURL("base", w1data);
|
||||
result = `<div class='ls-imgdiff-wrap'>
|
||||
<div class='overlay'>
|
||||
<img class='img-base' src="${src}">
|
||||
</div>
|
||||
</div>`;
|
||||
this.contentView.removeClass("op-pre");
|
||||
this.prepareContentView(false);
|
||||
if (this.currentDeleted) {
|
||||
this.appendDeletedNotice(false);
|
||||
}
|
||||
this.appendImageDiff(src);
|
||||
} else {
|
||||
if (this.currentDeleted) {
|
||||
this.appendDeletedNotice();
|
||||
}
|
||||
this.contentView.appendText("Binary file");
|
||||
}
|
||||
} else {
|
||||
result = escapeStringToHTML(w1data);
|
||||
if (this.currentDeleted) {
|
||||
this.appendDeletedNotice();
|
||||
}
|
||||
this.appendSearchHighlightedText(this.contentView, w1data);
|
||||
}
|
||||
}
|
||||
if (result == undefined) result = typeof w1data == "string" ? escapeStringToHTML(w1data) : "Binary file";
|
||||
|
||||
if (this.searchKeyword && typeof result == "string" && !this.showDiff) {
|
||||
const regex = new RegExp(`(${this.searchKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
|
||||
result = result.replace(regex, "<mark>$1</mark>");
|
||||
}
|
||||
|
||||
this.contentView.innerHTML =
|
||||
(this.currentDeleted ? "(At this revision, the file has been deleted)\n" : "") + result;
|
||||
}
|
||||
// Reset diff navigation after content changes
|
||||
this.resetDiffNavigation();
|
||||
@@ -272,15 +320,14 @@ export class DocumentHistoryModal extends Modal {
|
||||
if (direction === "next") {
|
||||
this.currentDiffIndex = (this.currentDiffIndex + 1) % diffElements.length;
|
||||
} else {
|
||||
this.currentDiffIndex =
|
||||
this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1;
|
||||
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}`;
|
||||
this.diffNavIndicator.setText(`${this.currentDiffIndex + 1}/${diffElements.length}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -291,9 +338,9 @@ export class DocumentHistoryModal extends Modal {
|
||||
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";
|
||||
this.diffNavIndicator.setText(diffElements.length > 0 ? `0/${diffElements.length}` : "\u2014");
|
||||
} else {
|
||||
this.diffNavIndicator.textContent = "\u2014";
|
||||
this.diffNavIndicator.setText("\u2014");
|
||||
}
|
||||
}
|
||||
this.updateDiffNavVisibility();
|
||||
@@ -317,8 +364,8 @@ export class DocumentHistoryModal extends Modal {
|
||||
this.currentSearchIndex = -1;
|
||||
|
||||
if (!keyword) {
|
||||
this.searchResultIndicator.textContent = "";
|
||||
this.searchProgressIndicator.textContent = "";
|
||||
this.searchResultIndicator.setText("");
|
||||
this.searchProgressIndicator.setText("");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -327,7 +374,7 @@ export class DocumentHistoryModal extends Modal {
|
||||
const totalRevs = this.revs_info.length;
|
||||
const end = Math.min(totalRevs, limit);
|
||||
|
||||
this.searchProgressIndicator.textContent = "Searching...";
|
||||
this.searchProgressIndicator.setText("Searching...");
|
||||
|
||||
const dmp = new diff_match_patch();
|
||||
|
||||
@@ -336,7 +383,7 @@ export class DocumentHistoryModal extends Modal {
|
||||
const revInfo = this.revs_info[i];
|
||||
const rev = revInfo.rev;
|
||||
|
||||
this.searchProgressIndicator.textContent = `Searching ${i + 1}/${end}...`;
|
||||
this.searchProgressIndicator.setText(`Searching ${i + 1}/${end}...`);
|
||||
|
||||
const doc = await db.getDBEntry(this.file, { rev: rev }, false, false, true);
|
||||
if (doc === false) continue;
|
||||
@@ -364,8 +411,10 @@ export class DocumentHistoryModal extends Modal {
|
||||
const diffs = dmp.diff_main(olderContent, content);
|
||||
let foundInDiff = false;
|
||||
for (const d of diffs) {
|
||||
if ((d[0] === DIFF_INSERT || d[0] === DIFF_DELETE) &&
|
||||
d[1].toLocaleLowerCase().includes(keywordLower)) {
|
||||
if (
|
||||
(d[0] === DIFF_INSERT || d[0] === DIFF_DELETE) &&
|
||||
d[1].toLocaleLowerCase().includes(keywordLower)
|
||||
) {
|
||||
foundInDiff = true;
|
||||
break;
|
||||
}
|
||||
@@ -379,16 +428,16 @@ export class DocumentHistoryModal extends Modal {
|
||||
}
|
||||
}
|
||||
|
||||
this.searchProgressIndicator.textContent = "Done";
|
||||
this.searchProgressIndicator.setText("Done");
|
||||
this.updateSearchUI();
|
||||
}
|
||||
|
||||
updateSearchUI() {
|
||||
if (this.searchResults.length === 0) {
|
||||
this.searchResultIndicator.textContent = this.searchKeyword ? "No matches found" : "";
|
||||
this.searchResultIndicator.setText(this.searchKeyword ? "No matches found" : "");
|
||||
} else {
|
||||
const current = this.currentSearchIndex >= 0 ? this.currentSearchIndex + 1 : 0;
|
||||
this.searchResultIndicator.textContent = `${current}/${this.searchResults.length} matches`;
|
||||
this.searchResultIndicator.setText(`${current}/${this.searchResults.length} matches`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,7 +455,7 @@ export class DocumentHistoryModal extends Modal {
|
||||
this.range.value = `${this.revs_info.length - 1 - match.index}`;
|
||||
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
||||
this.updateSearchUI();
|
||||
|
||||
|
||||
// If it's a diff match, make sure Highlight diff is on
|
||||
if (match.matchType === "Diff" && !this.showDiff) {
|
||||
// We could auto-enable it, but maybe just notify the user?
|
||||
@@ -425,13 +474,10 @@ export class DocumentHistoryModal extends Modal {
|
||||
const searchRow = contentEl.createDiv("");
|
||||
searchRow.addClass("op-info");
|
||||
searchRow.addClass("search-row");
|
||||
searchRow.style.display = "flex";
|
||||
searchRow.style.gap = "5px";
|
||||
searchRow.style.alignItems = "center";
|
||||
searchRow.style.marginBottom = "10px";
|
||||
searchRow.addClass("history-search-row");
|
||||
|
||||
const searchInput = searchRow.createEl("input", { type: "text", placeholder: "Search in history (last 100)..." });
|
||||
searchInput.style.flexGrow = "1";
|
||||
searchInput.addClass("history-search-input");
|
||||
searchInput.addEventListener("input", () => {
|
||||
if (this.searchTimeout) {
|
||||
clearTimeout(this.searchTimeout);
|
||||
@@ -451,12 +497,10 @@ export class DocumentHistoryModal extends Modal {
|
||||
});
|
||||
|
||||
this.searchResultIndicator = searchRow.createEl("span", { text: "" });
|
||||
this.searchResultIndicator.style.fontSize = "0.8em";
|
||||
this.searchResultIndicator.style.minWidth = "80px";
|
||||
this.searchResultIndicator.addClass("history-search-result-indicator");
|
||||
|
||||
this.searchProgressIndicator = searchRow.createEl("span", { text: "" });
|
||||
this.searchProgressIndicator.style.fontSize = "0.8em";
|
||||
this.searchProgressIndicator.style.color = "var(--text-muted)";
|
||||
this.searchProgressIndicator.addClass("history-search-progress-indicator");
|
||||
|
||||
const divView = contentEl.createDiv("");
|
||||
divView.addClass("op-flex");
|
||||
@@ -473,31 +517,24 @@ export class DocumentHistoryModal extends Modal {
|
||||
const diffOptionsRow = contentEl.createDiv("");
|
||||
diffOptionsRow.addClass("op-info");
|
||||
diffOptionsRow.addClass("diff-options-row");
|
||||
diffOptionsRow.style.display = "flex";
|
||||
diffOptionsRow.style.justifyContent = "space-between";
|
||||
diffOptionsRow.style.alignItems = "center";
|
||||
diffOptionsRow.addClass("history-diff-options-row");
|
||||
|
||||
const highlightDiffContainer = diffOptionsRow.createDiv("");
|
||||
highlightDiffContainer.style.display = "flex";
|
||||
highlightDiffContainer.style.alignItems = "center";
|
||||
highlightDiffContainer.addClass("history-highlight-diff-container");
|
||||
|
||||
highlightDiffContainer.createEl("label", {}, (label) => {
|
||||
label.style.display = "flex";
|
||||
label.style.alignItems = "center";
|
||||
label.style.gap = "4px";
|
||||
label.appendChild(
|
||||
createEl("input", { type: "checkbox" }, (checkbox) => {
|
||||
if (this.showDiff) {
|
||||
checkbox.checked = true;
|
||||
}
|
||||
checkbox.addEventListener("input", (evt: any) => {
|
||||
this.showDiff = checkbox.checked;
|
||||
localStorage.setItem("ols-history-highlightdiff", this.showDiff == true ? "1" : "");
|
||||
this.updateDiffNavVisibility();
|
||||
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
||||
});
|
||||
})
|
||||
);
|
||||
label.addClass("history-highlight-diff-label");
|
||||
label.createEl("input", { type: "checkbox" }, (checkbox) => {
|
||||
if (this.showDiff) {
|
||||
checkbox.checked = true;
|
||||
}
|
||||
checkbox.addEventListener("input", (evt: any) => {
|
||||
this.showDiff = checkbox.checked;
|
||||
this.app.saveLocalStorage("ols-history-highlightdiff", this.showDiff == true ? "1" : null);
|
||||
this.updateDiffNavVisibility();
|
||||
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
||||
});
|
||||
});
|
||||
label.appendText("Highlight diff");
|
||||
});
|
||||
|
||||
@@ -505,7 +542,6 @@ export class DocumentHistoryModal extends Modal {
|
||||
this.diffNavContainer = diffOptionsRow.createDiv("");
|
||||
this.diffNavContainer.addClass("diff-nav");
|
||||
this.diffNavContainer.style.display = this.showDiff ? "flex" : "none";
|
||||
this.diffNavContainer.style.marginLeft = "auto";
|
||||
|
||||
this.diffNavContainer.createEl("button", { text: "\u25B2 Prev" }, (e) => {
|
||||
e.addClass("diff-nav-btn");
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { App, Modal } from "../../../deps.ts";
|
||||
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 { escapeStringToHTML } from "../../../lib/src/string_and_binary/convert.ts";
|
||||
import { delay } from "../../../lib/src/common/utils.ts";
|
||||
import { eventHub } from "../../../common/events.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);
|
||||
}
|
||||
|
||||
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() {
|
||||
const { contentEl } = this;
|
||||
// Send cancel signal for the previous merge dialogue
|
||||
@@ -64,25 +82,21 @@ export class ConflictResolveModal extends Modal {
|
||||
const div = contentEl.createDiv("");
|
||||
div.addClass("op-scrollable");
|
||||
div.addClass("ls-dialog");
|
||||
let diff = "";
|
||||
let diffLength = 0;
|
||||
for (const v of this.result.diff) {
|
||||
const x1 = v[0];
|
||||
const x2 = v[1];
|
||||
diffLength += x2.length;
|
||||
if (diffLength > 100 * 1024) {
|
||||
continue;
|
||||
}
|
||||
if (x1 == DIFF_DELETE) {
|
||||
diff +=
|
||||
"<span class='deleted'>" +
|
||||
escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") +
|
||||
"</span>";
|
||||
this.appendDiffFragment(div, x2, "deleted");
|
||||
div.createEl("span", { text: x2, cls: "deleted normal conflict-dev-name" });
|
||||
} else if (x1 == DIFF_EQUAL) {
|
||||
diff +=
|
||||
"<span class='normal'>" +
|
||||
escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") +
|
||||
"</span>";
|
||||
this.appendDiffFragment(div, x2, "normal");
|
||||
} else if (x1 == DIFF_INSERT) {
|
||||
diff +=
|
||||
"<span class='added'>" +
|
||||
escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") +
|
||||
"</span>";
|
||||
this.appendDiffFragment(div, x2, "added");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,8 +106,8 @@ export class ConflictResolveModal extends Modal {
|
||||
new Date(this.result.left.mtime).toLocaleString() + (this.result.left.deleted ? " (Deleted)" : "");
|
||||
const date2 =
|
||||
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>
|
||||
<span class='added'><span class='conflict-dev-name'>${this.remoteName}</span>: ${date2}</span><br>`;
|
||||
this.appendVersionInfo(div2, "deleted", this.localName, date1);
|
||||
this.appendVersionInfo(div2, "added", this.remoteName, date2);
|
||||
contentEl.createEl("button", { text: `Use ${this.localName}` }, (e) =>
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.right.rev))
|
||||
).style.marginRight = "4px";
|
||||
@@ -108,11 +122,9 @@ export class ConflictResolveModal extends Modal {
|
||||
contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) =>
|
||||
e.addEventListener("click", () => this.sendResponse(CANCELLED))
|
||||
).style.marginRight = "4px";
|
||||
diff = diff.replace(/\n/g, "<br>");
|
||||
if (diff.length > 100 * 1024) {
|
||||
if (diffLength > 100 * 1024) {
|
||||
div.empty();
|
||||
div.innerText = "(Too large diff to display)";
|
||||
} else {
|
||||
div.innerHTML = diff;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
EVENT_ON_UNRESOLVED_ERROR,
|
||||
} from "../../common/events.ts";
|
||||
import { AbstractObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
import { addIcon, normalizePath, Notice } from "../../deps.ts";
|
||||
import { addIcon, debounce, normalizePath, Notice, stringifyYaml, type WorkspaceLeaf } from "../../deps.ts";
|
||||
import { LOG_LEVEL_NOTICE, setGlobalLogFunction } from "octagonal-wheels/common/logger";
|
||||
import { LogPaneView, VIEW_TYPE_LOG } from "./Log/LogPaneView.ts";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
@@ -41,6 +41,8 @@ import {
|
||||
} from "@lib/string_and_binary/path.ts";
|
||||
import { MARK_LOG_NETWORK_ERROR, MARK_LOG_SEPARATOR } from "@lib/services/lib/logUtils.ts";
|
||||
import { NetworkWarningStyles } from "@lib/common/models/setting.const.ts";
|
||||
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
|
||||
import { generateReport } from "@/common/reportTool.ts";
|
||||
|
||||
// This module cannot be a core module because it depends on the Obsidian UI.
|
||||
|
||||
@@ -50,18 +52,51 @@ const globalLogFunction = (message: any, level?: number, key?: string) => {
|
||||
const messageX =
|
||||
message instanceof Error
|
||||
? new LiveSyncError("[Error Logged]: " + message.message, { cause: message })
|
||||
: message;
|
||||
: typeof message === "string"
|
||||
? message
|
||||
: JSON.stringify(message);
|
||||
const entry = { message: messageX, level, key } as LogEntry;
|
||||
recentLogEntries.value = [...recentLogEntries.value, entry];
|
||||
};
|
||||
|
||||
setGlobalLogFunction(globalLogFunction);
|
||||
let recentLogs = [] as string[];
|
||||
// Keep the recent logs in memory for display, but also keep a longer history in logForDump for when the user wants to see more logs.
|
||||
// logForDump is not reactive and is only used for dumping logs when requested, while recentLogs is reactive and is used for displaying logs in the UI.
|
||||
const logForDump = [] as string[];
|
||||
|
||||
function addLog(log: string) {
|
||||
recentLogs = [...recentLogs, log].splice(-200);
|
||||
logMessages.value = recentLogs;
|
||||
logForDump.push(log);
|
||||
while (logForDump.length > 1000) {
|
||||
logForDump.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Display log is kept separate from the full log history to optimize performance and memory usage.
|
||||
// And debounce the updates to the display log to avoid excessive UI updates when there are many log entries in a short time.
|
||||
const logForDisplay = [] as string[];
|
||||
|
||||
const updateLogMessage = debounce(() => {
|
||||
logMessages.value = [...logForDisplay];
|
||||
}, 25);
|
||||
function addDisplayLog(log: string) {
|
||||
logForDisplay.push(log);
|
||||
while (logForDisplay.length > 200) {
|
||||
logForDisplay.shift();
|
||||
}
|
||||
updateLogMessage();
|
||||
}
|
||||
|
||||
const redactPatterns = [/PBKDF2 salt \(Security Seed\):.*$/];
|
||||
function redactLog(log: string) {
|
||||
let redactedLog = log;
|
||||
for (const pattern of redactPatterns) {
|
||||
redactedLog = redactedLog.replace(pattern, (match) => {
|
||||
return match.split(":")[0] + ": [REDACTED]";
|
||||
});
|
||||
}
|
||||
return redactedLog;
|
||||
}
|
||||
|
||||
// logStore.intercept(e => e.slice(Math.min(e.length - 200, 0)));
|
||||
|
||||
const showDebugLog = false;
|
||||
@@ -86,15 +121,15 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
// const emptyMark = `\u{2003}`;
|
||||
function padLeftSpComputed(numI: ReactiveValue<number>, mark: string) {
|
||||
const formatted = reactiveSource("");
|
||||
let timer: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||
let timer: number | undefined = undefined;
|
||||
let maxLen = 1;
|
||||
numI.onChanged((numX) => {
|
||||
const num = numX.value;
|
||||
const numLen = `${Math.abs(num)}`.length + 1;
|
||||
maxLen = maxLen < numLen ? numLen : maxLen;
|
||||
if (timer) clearTimeout(timer);
|
||||
if (timer) compatGlobal.clearTimeout(timer);
|
||||
if (num == 0) {
|
||||
timer = setTimeout(() => {
|
||||
timer = compatGlobal.setTimeout(() => {
|
||||
formatted.value = "";
|
||||
maxLen = 1;
|
||||
}, 3000);
|
||||
@@ -323,7 +358,7 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
if (this.nextFrameQueue) {
|
||||
return;
|
||||
}
|
||||
this.nextFrameQueue = requestAnimationFrame(() => {
|
||||
this.nextFrameQueue = compatGlobal.requestAnimationFrame(() => {
|
||||
this.nextFrameQueue = undefined;
|
||||
const { message, status } = this.statusBarLabels.value;
|
||||
// const recent = logMessages.value;
|
||||
@@ -346,7 +381,8 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
(a, b) => (a < b.ttl ? a : b.ttl),
|
||||
Number.MAX_SAFE_INTEGER
|
||||
);
|
||||
if (this.logLines.length > 0) setTimeout(() => this.applyStatusBarText(), minimumNext - now);
|
||||
if (this.logLines.length > 0)
|
||||
compatGlobal.setTimeout(() => this.applyStatusBarText(), minimumNext - now);
|
||||
const recent = this.logLines.map((e) => e.message);
|
||||
const recentLogs = recent.reverse().join("\n");
|
||||
if (isDirty("recentLogs", recentLogs)) this.logHistory!.innerText = recentLogs;
|
||||
@@ -368,7 +404,7 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
if (this.statusDiv) {
|
||||
this.statusDiv.remove();
|
||||
}
|
||||
document.querySelectorAll(`.livesync-status`)?.forEach((e) => e.remove());
|
||||
compatGlobal.document.querySelectorAll(`.livesync-status`)?.forEach((e) => e.remove());
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
_everyOnloadStart(): Promise<boolean> {
|
||||
@@ -390,7 +426,28 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
void this.services.API.showWindow(VIEW_TYPE_LOG);
|
||||
},
|
||||
});
|
||||
this.registerView(VIEW_TYPE_LOG, (leaf) => new LogPaneView(leaf, this.plugin));
|
||||
this.addCommand({
|
||||
id: "dump-debug-info",
|
||||
name: "Generate full report for opening the issue with debug info",
|
||||
callback: async () => {
|
||||
const recentLog = [...logForDump];
|
||||
const report = await generateReport(this.services.setting.currentSettings(), this.core);
|
||||
const info = {
|
||||
...report,
|
||||
recentLog: recentLog.map(redactLog),
|
||||
};
|
||||
const yaml = `\`\`\`\`
|
||||
# ---- Debug Info Dump ----
|
||||
${stringifyYaml(info)}
|
||||
\`\`\`\``;
|
||||
if (await this.services.UI.promptCopyToClipboard("Debug info", yaml)) {
|
||||
new Notice(
|
||||
"Debug info copied to clipboard. You can paste it in the issue. Be careful as it may contain sensitive information, review it before sharing."
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
this.registerView(VIEW_TYPE_LOG, (leaf: WorkspaceLeaf) => new LogPaneView(leaf, this.plugin));
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
private _everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||
@@ -404,7 +461,7 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
void this.setFileStatus();
|
||||
});
|
||||
|
||||
const w = document.querySelectorAll(`.livesync-status`);
|
||||
const w = compatGlobal.document.querySelectorAll(`.livesync-status`);
|
||||
w.forEach((e) => e.remove());
|
||||
|
||||
this.observeForLogs();
|
||||
@@ -421,6 +478,8 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
this.statusBar?.addClass("syncstatusbar");
|
||||
}
|
||||
this.adjustStatusDivPosition();
|
||||
this._log("Log module loaded", LOG_LEVEL_INFO);
|
||||
this._log("Verbose log", LOG_LEVEL_VERBOSE);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
@@ -444,11 +503,12 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
if (level == LOG_LEVEL_DEBUG && !showDebugLog) {
|
||||
return;
|
||||
}
|
||||
let memoOnly = false;
|
||||
if (level <= LOG_LEVEL_INFO && this.settings && this.settings.lessInformationInLog) {
|
||||
return;
|
||||
memoOnly = true;
|
||||
}
|
||||
if (this.settings && !this.settings.showVerboseLog && level == LOG_LEVEL_VERBOSE) {
|
||||
return;
|
||||
memoOnly = true;
|
||||
}
|
||||
const vaultName = this.services.vault.getVaultName();
|
||||
const now = new Date();
|
||||
@@ -469,6 +529,15 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
? `${errorInfo}`
|
||||
: JSON.stringify(message, null, 2);
|
||||
const newMessage = timestamp + "->" + messageContent;
|
||||
|
||||
if (this.settings?.writeLogToTheFile) {
|
||||
this.writeLogToTheFile(now, vaultName, newMessage);
|
||||
}
|
||||
addLog(newMessage);
|
||||
if (memoOnly) {
|
||||
return;
|
||||
}
|
||||
addDisplayLog(newMessage);
|
||||
if (message instanceof Error) {
|
||||
console.error(vaultName + ":" + newMessage);
|
||||
} else if (level >= LOG_LEVEL_INFO) {
|
||||
@@ -479,10 +548,6 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
if (!this.settings?.showOnlyIconsOnEditor) {
|
||||
this.statusLog.value = messageContent;
|
||||
}
|
||||
if (this.settings?.writeLogToTheFile) {
|
||||
this.writeLogToTheFile(now, vaultName, newMessage);
|
||||
}
|
||||
addLog(newMessage);
|
||||
this.logLines.push({ ttl: now.getTime() + 3000, message: newMessage });
|
||||
|
||||
if (level >= LOG_LEVEL_NOTICE) {
|
||||
|
||||
@@ -35,6 +35,7 @@ export function paneAdvanced(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
||||
clampMin: 10,
|
||||
onUpdate: this.onlyOnCouchDB,
|
||||
});
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("autoAcceptCompatibleTweak");
|
||||
// new Setting(paneEl)
|
||||
// .setClass("wizardHidden")
|
||||
// .autoWireToggle("sendChunksBulk", { onUpdate: onlyOnCouchDB })
|
||||
|
||||
@@ -43,10 +43,13 @@ export function paneChangeLog(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElem
|
||||
// tmpDiv.addClass("sls-header-button");
|
||||
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)) {
|
||||
const informationButtonDiv = informationDivEl.appendChild(tmpDiv);
|
||||
informationButtonDiv.querySelector("button")?.addEventListener("click", () => {
|
||||
readEverythingButton.addEventListener("click", () => {
|
||||
fireAndForget(async () => {
|
||||
this.editingSettings.lastReadUpdates = lastVersion;
|
||||
await this.saveAllDirtySettings();
|
||||
|
||||
@@ -39,6 +39,7 @@ import { EVENT_REQUEST_SHOW_HISTORY } from "../../../common/obsidianEvents.ts";
|
||||
import { generateCredentialObject } from "../../../lib/src/replication/httplib.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
import { generateReport } from "@/common/reportTool.ts";
|
||||
export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
|
||||
// const hatchWarn = this.createEl(paneEl, "div", { text: `To stop the boot up sequence for fixing problems on databases, you can put redflag.md on top of your vault (Rebooting obsidian is required).` });
|
||||
// hatchWarn.addClass("op-warn-info");
|
||||
@@ -69,140 +70,14 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
eventHub.emitEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE);
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(paneEl).setName($msg("Prepare the 'report' to create an issue")).addButton((button) =>
|
||||
button
|
||||
.setButtonText($msg("Copy Report to clipboard"))
|
||||
.setCta()
|
||||
.setDisabled(false)
|
||||
.onClick(async () => {
|
||||
let responseConfig: any = {};
|
||||
const REDACTED = "𝑅𝐸𝐷𝐴𝐶𝑇𝐸𝐷";
|
||||
if (this.editingSettings.remoteType == REMOTE_COUCHDB) {
|
||||
try {
|
||||
const credential = generateCredentialObject(this.editingSettings);
|
||||
const customHeaders = parseHeaderValues(this.editingSettings.couchDB_CustomHeaders);
|
||||
const r = await requestToCouchDBWithCredentials(
|
||||
this.editingSettings.couchDB_URI,
|
||||
credential,
|
||||
window.origin,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
customHeaders
|
||||
);
|
||||
|
||||
Logger(JSON.stringify(r.json, null, 2));
|
||||
|
||||
responseConfig = r.json;
|
||||
responseConfig["couch_httpd_auth"].secret = REDACTED;
|
||||
responseConfig["couch_httpd_auth"].authentication_db = REDACTED;
|
||||
responseConfig["couch_httpd_auth"].authentication_redirect = REDACTED;
|
||||
responseConfig["couchdb"].uuid = REDACTED;
|
||||
responseConfig["admins"] = REDACTED;
|
||||
delete responseConfig["jwt_keys"];
|
||||
if ("secret" in responseConfig["chttpd_auth"])
|
||||
responseConfig["chttpd_auth"].secret = REDACTED;
|
||||
} catch (ex) {
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
responseConfig = {
|
||||
error: "Requesting information from the remote CouchDB has failed. If you are using IBM Cloudant, this is normal behaviour.",
|
||||
};
|
||||
}
|
||||
} else if (this.editingSettings.remoteType == REMOTE_MINIO) {
|
||||
responseConfig = { error: "Object Storage Synchronisation" };
|
||||
//
|
||||
}
|
||||
const defaultKeys = Object.keys(DEFAULT_SETTINGS) as (keyof ObsidianLiveSyncSettings)[];
|
||||
const pluginConfig = JSON.parse(JSON.stringify(this.editingSettings)) as ObsidianLiveSyncSettings;
|
||||
const pluginKeys = Object.keys(pluginConfig);
|
||||
for (const key of pluginKeys) {
|
||||
if (defaultKeys.includes(key as any)) continue;
|
||||
delete pluginConfig[key as keyof ObsidianLiveSyncSettings];
|
||||
}
|
||||
|
||||
pluginConfig.couchDB_DBNAME = REDACTED;
|
||||
pluginConfig.couchDB_PASSWORD = REDACTED;
|
||||
const scheme = pluginConfig.couchDB_URI.startsWith("http:")
|
||||
? "(HTTP)"
|
||||
: pluginConfig.couchDB_URI.startsWith("https:")
|
||||
? "(HTTPS)"
|
||||
: "";
|
||||
pluginConfig.couchDB_URI = isCloudantURI(pluginConfig.couchDB_URI)
|
||||
? "cloudant"
|
||||
: `self-hosted${scheme}`;
|
||||
pluginConfig.couchDB_USER = REDACTED;
|
||||
pluginConfig.passphrase = REDACTED;
|
||||
pluginConfig.encryptedPassphrase = REDACTED;
|
||||
pluginConfig.encryptedCouchDBConnection = REDACTED;
|
||||
pluginConfig.accessKey = REDACTED;
|
||||
pluginConfig.secretKey = REDACTED;
|
||||
const redact = (source: string) => `${REDACTED}(${source.length} letters)`;
|
||||
const toSchemeOnly = (uri: string) => {
|
||||
try {
|
||||
return `${new URL(uri).protocol}//`;
|
||||
} catch {
|
||||
const matched = uri.match(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//);
|
||||
return matched?.[0] ?? REDACTED;
|
||||
}
|
||||
};
|
||||
pluginConfig.remoteConfigurations = Object.fromEntries(
|
||||
Object.entries(pluginConfig.remoteConfigurations || {}).map(([id, config]) => [
|
||||
id,
|
||||
{
|
||||
...config,
|
||||
uri: toSchemeOnly(config.uri),
|
||||
},
|
||||
])
|
||||
);
|
||||
pluginConfig.region = redact(pluginConfig.region);
|
||||
pluginConfig.bucket = redact(pluginConfig.bucket);
|
||||
pluginConfig.pluginSyncExtendedSetting = {};
|
||||
pluginConfig.P2P_AppID = redact(pluginConfig.P2P_AppID);
|
||||
pluginConfig.P2P_passphrase = redact(pluginConfig.P2P_passphrase);
|
||||
pluginConfig.P2P_roomID = redact(pluginConfig.P2P_roomID);
|
||||
pluginConfig.P2P_relays = redact(pluginConfig.P2P_relays);
|
||||
pluginConfig.jwtKey = redact(pluginConfig.jwtKey);
|
||||
pluginConfig.jwtSub = redact(pluginConfig.jwtSub);
|
||||
pluginConfig.jwtKid = redact(pluginConfig.jwtKid);
|
||||
pluginConfig.bucketCustomHeaders = redact(pluginConfig.bucketCustomHeaders);
|
||||
pluginConfig.couchDB_CustomHeaders = redact(pluginConfig.couchDB_CustomHeaders);
|
||||
pluginConfig.P2P_turnCredential = redact(pluginConfig.P2P_turnCredential);
|
||||
pluginConfig.P2P_turnUsername = redact(pluginConfig.P2P_turnUsername);
|
||||
pluginConfig.P2P_turnServers = `(${pluginConfig.P2P_turnServers.split(",").length} servers configured)`;
|
||||
const endpoint = pluginConfig.endpoint;
|
||||
if (endpoint == "") {
|
||||
pluginConfig.endpoint = "Not configured or AWS";
|
||||
} else {
|
||||
const endpointScheme = pluginConfig.endpoint.startsWith("http:")
|
||||
? "(HTTP)"
|
||||
: pluginConfig.endpoint.startsWith("https:")
|
||||
? "(HTTPS)"
|
||||
: "";
|
||||
pluginConfig.endpoint = `${endpoint.indexOf(".r2.cloudflarestorage.") !== -1 ? "R2" : "self-hosted?"}(${endpointScheme})`;
|
||||
}
|
||||
const obsidianInfo = {
|
||||
navigator: navigator.userAgent,
|
||||
fileSystem: this.core.services.vault.isStorageInsensitive() ? "insensitive" : "sensitive",
|
||||
};
|
||||
const msgConfig = `# ---- Obsidian info ----
|
||||
${stringifyYaml(obsidianInfo)}
|
||||
---
|
||||
# ---- remote config ----
|
||||
${stringifyYaml(responseConfig)}
|
||||
---
|
||||
# ---- Plug-in config ----
|
||||
${stringifyYaml({
|
||||
version: this.manifestVersion,
|
||||
...pluginConfig,
|
||||
})}`;
|
||||
console.log(msgConfig);
|
||||
if ((await this.services.UI.promptCopyToClipboard("Generated report", msgConfig)) == true) {
|
||||
// await navigator.clipboard.writeText(msgConfig);
|
||||
// Logger(
|
||||
// `Generated report has been copied to clipboard. Please report the issue with this! Thank you for your cooperation!`,
|
||||
// LOG_LEVEL_NOTICE
|
||||
// );
|
||||
}
|
||||
await this.app.commands.executeCommandById("obsidian-livesync:dump-debug-info");
|
||||
})
|
||||
);
|
||||
new Setting(paneEl)
|
||||
|
||||
@@ -121,13 +121,13 @@ export function paneSetup(
|
||||
const repo = "vrtmrz/obsidian-livesync";
|
||||
const topPath = $msg("obsidianLiveSyncSettingTab.linkTroubleshooting");
|
||||
const rawRepoURI = `https://raw.githubusercontent.com/${repo}/main`;
|
||||
this.createEl(
|
||||
paneEl,
|
||||
"div",
|
||||
"",
|
||||
(el) =>
|
||||
(el.innerHTML = `<a href='https://github.com/${repo}/blob/main${topPath}' target="_blank">${$msg("obsidianLiveSyncSettingTab.linkOpenInBrowser")}</a>`)
|
||||
);
|
||||
this.createEl(paneEl, "div", "", (el) => {
|
||||
el.createEl("a", { text: $msg("obsidianLiveSyncSettingTab.linkOpenInBrowser") }, (anchor) => {
|
||||
anchor.href = `https://github.com/${repo}/blob/main${topPath}`;
|
||||
anchor.target = "_blank";
|
||||
anchor.rel = "noopener";
|
||||
});
|
||||
});
|
||||
const troubleShootEl = this.createEl(paneEl, "div", {
|
||||
text: "",
|
||||
cls: "sls-troubleshoot-preview",
|
||||
|
||||
@@ -13,7 +13,7 @@ export const checkConfig = async (
|
||||
Logger($msg("obsidianLiveSyncSettingTab.logCheckingDbConfig"), LOG_LEVEL_INFO);
|
||||
let isSuccessful = true;
|
||||
const emptyDiv = createDiv();
|
||||
emptyDiv.innerHTML = "<span></span>";
|
||||
emptyDiv.createSpan();
|
||||
checkResultDiv?.replaceChildren(...[emptyDiv]);
|
||||
const addResult = (msg: string, classes?: string[]) => {
|
||||
const tmpDiv = createDiv();
|
||||
@@ -21,7 +21,7 @@ export const checkConfig = async (
|
||||
if (classes) {
|
||||
tmpDiv.addClasses(classes);
|
||||
}
|
||||
tmpDiv.innerHTML = `${msg}`;
|
||||
tmpDiv.textContent = msg;
|
||||
checkResultDiv?.appendChild(tmpDiv);
|
||||
};
|
||||
try {
|
||||
@@ -47,9 +47,10 @@ export const checkConfig = async (
|
||||
if (!checkResultDiv) return;
|
||||
const tmpDiv = createDiv();
|
||||
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);
|
||||
x.querySelector("button")?.addEventListener("click", () => {
|
||||
fixButton.addEventListener("click", () => {
|
||||
fireAndForget(async () => {
|
||||
Logger($msg("obsidianLiveSyncSettingTab.logCouchDbConfigSet", { title, key, value }));
|
||||
const res = await requestToCouchDBWithCredentials(
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { generatePatchObj, isObjectDifferent } from "../../lib/src/common/utils.ts";
|
||||
import { isObjectDifferent } from "@lib/common/utils.ts";
|
||||
import Intro from "./SetupWizard/dialogs/Intro.svelte";
|
||||
import SelectMethodNewUser from "./SetupWizard/dialogs/SelectMethodNewUser.svelte";
|
||||
import SelectMethodExisting from "./SetupWizard/dialogs/SelectMethodExisting.svelte";
|
||||
@@ -23,6 +23,7 @@ import SetupRemoteP2P from "./SetupWizard/dialogs/SetupRemoteP2P.svelte";
|
||||
import SetupRemoteE2EE from "./SetupWizard/dialogs/SetupRemoteE2EE.svelte";
|
||||
import { decodeSettingsFromQRCodeData } from "../../lib/src/API/processSetting.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import { ConnectionStringParser } from "@lib/common/ConnectionString.ts";
|
||||
|
||||
/**
|
||||
* User modes for onboarding and setup
|
||||
@@ -194,8 +195,24 @@ export class SetupManager extends AbstractModule {
|
||||
return await this.onOnboard(userMode);
|
||||
}
|
||||
const newSetting = { ...currentSetting, ...p2pConf } as ObsidianLiveSyncSettings;
|
||||
// Apply remoteConfigurations
|
||||
if (newSetting.P2P_ActiveRemoteConfigurationId) {
|
||||
const id = newSetting.P2P_ActiveRemoteConfigurationId;
|
||||
const merged = {
|
||||
...newSetting,
|
||||
...p2pConf,
|
||||
} as ObsidianLiveSyncSettings;
|
||||
const uri = ConnectionStringParser.serialize({ type: "p2p", settings: merged });
|
||||
newSetting.remoteConfigurations[id] = {
|
||||
...newSetting.remoteConfigurations[id],
|
||||
uri,
|
||||
isEncrypted: false,
|
||||
};
|
||||
newSetting.P2P_ActiveRemoteConfigurationId = id;
|
||||
}
|
||||
if (activate) {
|
||||
newSetting.remoteType = REMOTE_P2P;
|
||||
newSetting.activeConfigurationId = newSetting.P2P_ActiveRemoteConfigurationId;
|
||||
}
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
}
|
||||
@@ -285,9 +302,9 @@ export class SetupManager extends AbstractModule {
|
||||
this._log("No changes in settings detected. Skipping applying settings from wizard.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
}
|
||||
const patch = generatePatchObj(this.settings, newConf);
|
||||
console.log(`Changes:`);
|
||||
console.dir(patch);
|
||||
// const patch = generatePatchObj(this.settings, newConf);
|
||||
// console.log(`Changes:`);
|
||||
// console.dir(patch);
|
||||
if (!activate) {
|
||||
extra();
|
||||
await this.applySetting(newConf, UserMode.ExistingUser);
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
const TYPE_CLOSE = "close";
|
||||
const TYPE_CLOSE = "close";
|
||||
type ResultType = typeof TYPE_CLOSE;
|
||||
type Props = {
|
||||
setResult: (result: ResultType) => void;
|
||||
setResult: (_result: ResultType) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
bind:value={userType}
|
||||
>
|
||||
This is an advanced option for users who do not have a URI or who wish to configure detailed settings.
|
||||
You can also select this option if you intend to use <strong>P2P (Peer-to-Peer) synchronisation</strong>
|
||||
instead of a CouchDB/S3 server — P2P requires no server setup at all.
|
||||
</Option>
|
||||
</Options>
|
||||
</Instruction>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script lang="ts">
|
||||
// import { delay } from "octagonal-wheels/promises";
|
||||
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
|
||||
import InputRow from "@/lib/src/UI/components/InputRow.svelte";
|
||||
import Password from "@/lib/src/UI/components/Password.svelte";
|
||||
import { PouchDB } from "../../../../lib/src/pouchdb/pouchdb-browser";
|
||||
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@lib/UI/components/Guidance.svelte";
|
||||
import Decision from "@lib/UI/components/Decision.svelte";
|
||||
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@lib/UI/components/InfoNote.svelte";
|
||||
import InputRow from "@lib/UI/components/InputRow.svelte";
|
||||
import Password from "@lib/UI/components/Password.svelte";
|
||||
import { PouchDB } from "@lib/pouchdb/pouchdb-browser";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
P2P_DEFAULT_SETTINGS,
|
||||
@@ -17,15 +17,15 @@
|
||||
type ObsidianLiveSyncSettings,
|
||||
type P2PConnectionInfo,
|
||||
type P2PSyncSetting,
|
||||
} from "../../../../lib/src/common/types";
|
||||
} from "@lib/common/types";
|
||||
|
||||
import { TrysteroReplicator } from "../../../../lib/src/replication/trystero/TrysteroReplicator";
|
||||
import type { ReplicatorHostEnv } from "../../../../lib/src/replication/trystero/types";
|
||||
import { copyTo, pickP2PSyncSettings, type SimpleStore } from "../../../../lib/src/common/utils";
|
||||
import { TrysteroReplicator } from "@lib/replication/trystero/TrysteroReplicator";
|
||||
import type { ReplicatorHostEnv } from "@lib/replication/trystero/types";
|
||||
import { copyTo, pickP2PSyncSettings, type SimpleStore } from "@lib/common/utils";
|
||||
import { onMount } from "svelte";
|
||||
import { getDialogContext, type GuestDialogProps } from "../../../../lib/src/UI/svelteDialog";
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "../../../../lib/src/common/types";
|
||||
import ExtraItems from "../../../../lib/src/UI/components/ExtraItems.svelte";
|
||||
import { getDialogContext, type GuestDialogProps } from "@lib/UI/svelteDialog";
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "@lib/common/types";
|
||||
import ExtraItems from "@lib/UI/components/ExtraItems.svelte";
|
||||
|
||||
const default_setting = pickP2PSyncSettings(DEFAULT_SETTINGS);
|
||||
let syncSetting = $state<P2PConnectionInfo>({ ...default_setting });
|
||||
@@ -39,18 +39,20 @@
|
||||
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
onMount(() => {
|
||||
let initialData: P2PSyncSetting | undefined = undefined;
|
||||
if (getInitialData) {
|
||||
const initialData = getInitialData();
|
||||
initialData = getInitialData();
|
||||
if (initialData) {
|
||||
copyTo(initialData, syncSetting);
|
||||
}
|
||||
if (context.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME)) {
|
||||
syncSetting.P2P_DevicePeerName = context.services.config.getSmallConfig(
|
||||
SETTING_KEY_P2P_DEVICE_NAME
|
||||
) as string;
|
||||
} else {
|
||||
syncSetting.P2P_DevicePeerName = "";
|
||||
}
|
||||
}
|
||||
const initialPeerName = (initialData?.P2P_DevicePeerName ?? "").trim();
|
||||
if (initialPeerName !== "") {
|
||||
return;
|
||||
}
|
||||
const cachedPeerName = context.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME);
|
||||
if (cachedPeerName) {
|
||||
syncSetting.P2P_DevicePeerName = cachedPeerName as string;
|
||||
}
|
||||
});
|
||||
function generateSetting() {
|
||||
@@ -100,7 +102,7 @@
|
||||
const dummyPouch = new PouchDB<EntryDoc>("dummy");
|
||||
const env: ReplicatorHostEnv = {
|
||||
settings: trialRemoteSetting,
|
||||
processReplicatedDocs: async (docs: any[]) => {
|
||||
processReplicatedDocs: async (_docs: any[]) => {
|
||||
return;
|
||||
},
|
||||
confirm: context.services.confirm,
|
||||
@@ -116,7 +118,7 @@
|
||||
await replicator.open();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// await delay(1000);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 1000));
|
||||
// Logger(`Checking known advertisements... (${i})`, LOG_LEVEL_INFO);
|
||||
if (replicator.knownAdvertisements.length > 0) {
|
||||
break;
|
||||
|
||||
@@ -61,10 +61,12 @@ export class ModuleLiveSyncMain extends AbstractModule {
|
||||
eventHub.onEvent(EVENT_SETTING_SAVED, (settings: ObsidianLiveSyncSettings) => {
|
||||
fireAndForget(async () => {
|
||||
try {
|
||||
await this.core.services.control.applySettings();
|
||||
const lang = this.core.services.setting.currentSettings()?.displayLanguage ?? undefined;
|
||||
const lang = this.core.services.setting.currentSettings()?.displayLanguage;
|
||||
if (lang !== undefined) {
|
||||
setLang(this.core.services.setting.currentSettings()?.displayLanguage);
|
||||
setLang(lang);
|
||||
}
|
||||
if (this.core.services.database.isDatabaseReady()) {
|
||||
await this.core.services.control.applySettings();
|
||||
}
|
||||
eventHub.emitEvent(EVENT_REQUEST_RELOAD_SETTING_TAB);
|
||||
} catch (e) {
|
||||
|
||||
@@ -38,6 +38,25 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
||||
}
|
||||
}
|
||||
|
||||
override async showWindowOnRight(viewType: string): Promise<void> {
|
||||
const existing = this.app.workspace.getLeavesOfType(viewType);
|
||||
if (existing.length > 0) {
|
||||
await this.app.workspace.revealLeaf(existing[0]);
|
||||
return;
|
||||
}
|
||||
const rightLeaf = this.app.workspace.getRightLeaf(false);
|
||||
if (rightLeaf) {
|
||||
await rightLeaf.setViewState({
|
||||
type: viewType,
|
||||
active: false,
|
||||
});
|
||||
await this.app.workspace.revealLeaf(rightLeaf);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.showWindow(viewType);
|
||||
}
|
||||
|
||||
private get app() {
|
||||
return this.context.app;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,22 @@ import { createServiceFeature } from "@lib/interfaces/ServiceModule";
|
||||
import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@lib/common/rosetta";
|
||||
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 } }) => {
|
||||
let isChanged = false;
|
||||
const settings = setting.currentSettings();
|
||||
if (settings.displayLanguage == "") {
|
||||
const obsidianLanguage = getLanguage();
|
||||
const obsidianLanguage = tryGetLanguage();
|
||||
if (
|
||||
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
|
||||
|
||||
@@ -5,7 +5,7 @@ import { FlagFilesHumanReadable, FlagFilesOriginal } from "@lib/common/models/re
|
||||
import FetchEverything from "../modules/features/SetupWizard/dialogs/FetchEverything.svelte";
|
||||
import RebuildEverything from "../modules/features/SetupWizard/dialogs/RebuildEverything.svelte";
|
||||
import { extractObject } from "octagonal-wheels/object";
|
||||
import { REMOTE_MINIO } from "@lib/common/models/setting.const";
|
||||
import { REMOTE_MINIO, REMOTE_P2P } from "@lib/common/models/setting.const";
|
||||
import type { ObsidianLiveSyncSettings } from "@lib/common/models/setting.type";
|
||||
import { TweakValuesShouldMatchedTemplate } from "@lib/common/models/tweak.definition";
|
||||
|
||||
@@ -200,6 +200,13 @@ export async function adjustSettingToRemoteIfNeeded(
|
||||
return;
|
||||
}
|
||||
|
||||
// P2P has no centralised remote configuration; skip to avoid a spurious
|
||||
// "Failed to connect to the remote server" error dialog.
|
||||
if (config.remoteType === REMOTE_P2P) {
|
||||
log("Remote configuration fetch skipped (P2P mode).", LOG_LEVEL_INFO);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remote configuration fetched and applied.
|
||||
if (await adjustSettingToRemote(host, log, config)) {
|
||||
config = host.services.setting.currentSettings();
|
||||
|
||||
@@ -4,7 +4,13 @@ import type { NecessaryServices } from "@lib/interfaces/ServiceModule";
|
||||
import { type UseP2PReplicatorResult } from "@/lib/src/replication/trystero/UseP2PReplicatorResult";
|
||||
import { P2PLogCollector } from "@/lib/src/replication/trystero/P2PLogCollector";
|
||||
import { P2PReplicatorPaneView, VIEW_TYPE_P2P } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneView";
|
||||
import {
|
||||
P2PServerStatusPaneView,
|
||||
VIEW_TYPE_P2P_SERVER_STATUS,
|
||||
} from "@/features/P2PSync/P2PReplicator/P2PServerStatusPaneView";
|
||||
import type { LiveSyncCore } from "@/main";
|
||||
import type { WorkspaceLeaf } from "@/deps";
|
||||
import { REMOTE_P2P } from "@lib/common/models/setting.const";
|
||||
|
||||
/**
|
||||
* ServiceFeature: P2P Replicator lifecycle management.
|
||||
@@ -33,6 +39,19 @@ export function useP2PReplicatorUI(
|
||||
core: LiveSyncCore,
|
||||
replicator: UseP2PReplicatorResult
|
||||
) {
|
||||
const api = host.services.API as {
|
||||
showWindow: (type: string) => Promise<void>;
|
||||
showWindowOnRight?: (type: string) => Promise<void>;
|
||||
registerWindow: (type: string, factory: (leaf: WorkspaceLeaf) => unknown) => void;
|
||||
addCommand: (command: { id: string; name: string; callback: () => void }) => unknown;
|
||||
addRibbonIcon: (
|
||||
icon: string,
|
||||
title: string,
|
||||
callback: () => void
|
||||
) => { addClass?: (name: string) => unknown } | undefined;
|
||||
getPlatform: () => string;
|
||||
};
|
||||
|
||||
// const env: LiveSyncTrysteroReplicatorEnv = { services: host.services as any };
|
||||
const getReplicator = () => replicator.replicator;
|
||||
const p2pLogCollector = new P2PLogCollector();
|
||||
@@ -43,33 +62,106 @@ export function useP2PReplicatorUI(
|
||||
|
||||
// Register view, commands and ribbon if a view factory is provided
|
||||
const viewType = VIEW_TYPE_P2P;
|
||||
const factory = (leaf: any) => {
|
||||
const factory = (leaf: WorkspaceLeaf) => {
|
||||
return new P2PReplicatorPaneView(leaf, core, {
|
||||
replicator: getReplicator(),
|
||||
p2pLogCollector,
|
||||
storeP2PStatusLine,
|
||||
});
|
||||
};
|
||||
const openPane = () => host.services.API.showWindow(viewType);
|
||||
host.services.API.registerWindow(viewType, factory);
|
||||
const statusFactory = (leaf: WorkspaceLeaf) => {
|
||||
return new P2PServerStatusPaneView(leaf, core, {
|
||||
replicator: getReplicator(),
|
||||
p2pLogCollector,
|
||||
storeP2PStatusLine,
|
||||
});
|
||||
};
|
||||
const openPane = () => api.showWindow(viewType);
|
||||
const openStatusPane = () => {
|
||||
if (api.showWindowOnRight) {
|
||||
return api.showWindowOnRight(VIEW_TYPE_P2P_SERVER_STATUS);
|
||||
}
|
||||
return api.showWindow(VIEW_TYPE_P2P_SERVER_STATUS);
|
||||
};
|
||||
api.registerWindow(viewType, factory);
|
||||
api.registerWindow(VIEW_TYPE_P2P_SERVER_STATUS, statusFactory);
|
||||
|
||||
host.services.appLifecycle.onInitialise.addHandler(() => {
|
||||
eventHub.onEvent(EVENT_REQUEST_OPEN_P2P, () => {
|
||||
void openPane();
|
||||
});
|
||||
|
||||
host.services.API.addCommand({
|
||||
api.addCommand({
|
||||
id: "open-p2p-replicator",
|
||||
name: "P2P Sync : Open P2P Replicator",
|
||||
name: "P2P Sync : Open P2P Replicator (Old UI)",
|
||||
callback: () => {
|
||||
void openPane();
|
||||
},
|
||||
});
|
||||
|
||||
host.services.API.addRibbonIcon("waypoints", "P2P Replicator", () => {
|
||||
void openPane();
|
||||
})?.addClass?.("livesync-ribbon-replicate-p2p");
|
||||
api.addCommand({
|
||||
id: "open-p2p-server-status",
|
||||
name: "P2P Sync : Open P2P Status",
|
||||
callback: () => {
|
||||
void openStatusPane();
|
||||
},
|
||||
});
|
||||
host.services.API.addCommand({
|
||||
id: "replicate-now-by-p2p-default-peer",
|
||||
name: "Replicate P2P to default peer",
|
||||
checkCallback: (isChecking: boolean) => {
|
||||
const settings = host.services.setting.currentSettings();
|
||||
if (isChecking) {
|
||||
if (settings.remoteType == REMOTE_P2P) return false;
|
||||
return replicator.replicator?.server?.isServing ?? false;
|
||||
}
|
||||
void replicator.replicator?.openReplication(settings, false, true, false);
|
||||
},
|
||||
});
|
||||
host.services.API.addCommand({
|
||||
id: "replicate-now-by-p2p",
|
||||
name: "Replicate now by P2P",
|
||||
checkCallback: (isChecking: boolean) => {
|
||||
const settings = host.services.setting.currentSettings();
|
||||
if (isChecking) {
|
||||
if (settings.remoteType == REMOTE_P2P) return false;
|
||||
return replicator.replicator?.server?.isServing ?? false;
|
||||
}
|
||||
void replicator.replicator?.openReplication(settings, false, true, false);
|
||||
},
|
||||
});
|
||||
|
||||
host.services.API.addCommand({
|
||||
id: "p2p-sync-targets",
|
||||
name: "P2P: Sync with targets",
|
||||
checkCallback: (isChecking: boolean) => {
|
||||
if (isChecking) {
|
||||
return replicator.replicator?.server?.isServing ?? false;
|
||||
}
|
||||
void replicator.replicator?.replicateFromCommand(true);
|
||||
},
|
||||
});
|
||||
|
||||
// api.addRibbonIcon("waypoints", "P2P Replicator", () => {
|
||||
// void openPane();
|
||||
// })?.addClass?.("livesync-ribbon-replicate-p2p");
|
||||
|
||||
api.addRibbonIcon("waypoints", "P2P Status", () => {
|
||||
void openStatusPane();
|
||||
})?.addClass?.("livesync-ribbon-p2p-server-status");
|
||||
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
|
||||
host.services.appLifecycle.onLayoutReady.addHandler(() => {
|
||||
if (api.getPlatform() !== "obsidian") {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
if (api.showWindowOnRight) {
|
||||
void api.showWindowOnRight(VIEW_TYPE_P2P_SERVER_STATUS);
|
||||
} else {
|
||||
void api.showWindow(VIEW_TYPE_P2P_SERVER_STATUS);
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
return { replicator: getReplicator(), p2pLogCollector, storeP2PStatusLine };
|
||||
|
||||
@@ -38,10 +38,20 @@ export class ObsidianVaultAdapter implements IVaultAdapter<TFile> {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user