mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-18 13:31:17 +00:00
Compare commits
11 Commits
enhance_fi
...
fix_ios_re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a08294ab16 | ||
|
|
d6bf453a6d | ||
|
|
e80cdc2dae | ||
|
|
60780678fd | ||
|
|
273e7a2b63 | ||
|
|
2572c54744 | ||
|
|
6a7c987985 | ||
|
|
6ef866a77c | ||
|
|
eea26dee74 | ||
|
|
ee24fe8c24 | ||
|
|
02673a1631 |
@@ -4,7 +4,7 @@
|
||||
|
||||
Self-hosted LiveSync is a community-developed synchronisation plug-in available on all Obsidian-compatible platforms. It leverages robust server solutions such as CouchDB or object storage systems (e.g., MinIO, S3, R2, etc.) to ensure reliable data synchronisation.
|
||||
|
||||
Additionally, it supports peer-to-peer synchronisation using WebRTC now (experimental), enabling you to synchronise your notes directly between devices without relying on a server.
|
||||
Additionally, it supports peer-to-peer synchronisation using WebRTC, enabling you to synchronise your notes directly between devices without relying on a server. Documentations is available for [Peer-to-Peer Synchronisation](./docs/p2p_sync_updates_2026.md).
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -20,15 +20,21 @@ Once you have saved the settings, return to the **P2P Status Pane** and click th
|
||||
## 3. Real-time Control
|
||||
The status pane in the right sidebar provides granular control over your synchronisation:
|
||||
|
||||
- **Active P2P Remote (new):** P2P now has its own active remote selection, separate from the normal active remote for database replication. Use the combo box next to the cog icon to choose which P2P remote configuration is active for P2P features.
|
||||
- **Create P2P Remote (new):** Use the **+** button to open the P2P setup dialogue and create a dedicated P2P remote configuration. This is recommended when no P2P active remote has been selected yet.
|
||||
- **Selection required (new):** If no P2P active remote is selected, the pane asks for selection before P2P target-related changes are saved.
|
||||
|
||||
- **Signalling Status:** Shows if you are connected to the relay (🟢 Online).
|
||||
- **Live-push (Broadcast):** Toggle "Broadcast changes" to notify other peers whenever you make an edit.
|
||||
- **Watch:** Enable "Watch" on specific peers to automatically pull changes when they broadcast. This creates a "LiveSync-like" experience.
|
||||
- **Sync (🔄/🔁):** Mark specific peers as **sync targets**. Peers marked here will be included when you run the **"P2P: Sync with targets"** command (see section 5). Click the button next to a peer to toggle it on (🔄, highlighted) or off (🔁). This setting is persisted in your configuration.
|
||||
- **Replicate now (🔄):** Start immediate bidirectional replication with a visible peer (Pull, then Push).
|
||||
- **Watch (🔔/🔕):** Enable "Watch" on specific peers to automatically pull changes when they broadcast. This creates a "LiveSync-like" experience.
|
||||
- **Sync target (🔗/⛓️💥):** Mark specific peers as **sync targets**. Peers marked here will be included when you run the **"P2P: Sync with targets"** command (see section 5). Click the button next to a peer to toggle it on (🔗, highlighted) or off (⛓️💥). This setting is persisted in your configuration.
|
||||
|
||||
## 4. Replication Dialogue
|
||||
If you want to synchronise with a specific peer manually, use the **Replication** command or button. This opens the **Replication Dialogue** listing available devices.
|
||||
|
||||
Inside the dialogue, the **Server Status** card at the top confirms you are still connected while performing the sync.
|
||||
The status card now shows a stable **Room ID suffix** above **Peer ID**. The Room ID suffix is better for identifying your P2P group, while Peer ID may change between connections.
|
||||
|
||||
Two actions are available per peer:
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "0.25.63",
|
||||
"minAppVersion": "1.7.2",
|
||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"author": "vorotamoroz",
|
||||
"authorUrl": "https://github.com/vrtmrz",
|
||||
"isDesktopOnly": false
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "0.25.64",
|
||||
"minAppVersion": "1.7.2",
|
||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"author": "vorotamoroz",
|
||||
"authorUrl": "https://github.com/vrtmrz",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "0.25.63",
|
||||
"version": "0.25.64",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-livesync",
|
||||
"version": "0.25.63",
|
||||
"version": "0.25.64",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.808.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "0.25.63",
|
||||
"version": "0.25.64",
|
||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,17 +8,22 @@
|
||||
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 }: Props = $props();
|
||||
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 ?? ""));
|
||||
|
||||
async function requestServerStatus() {
|
||||
await Promise.resolve(liveSyncReplicator.requestStatus());
|
||||
@@ -46,10 +51,14 @@
|
||||
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 ?? "");
|
||||
});
|
||||
|
||||
fireAndForget(async () => {
|
||||
await delay(100);
|
||||
@@ -59,6 +68,7 @@
|
||||
return () => {
|
||||
unsubscribe();
|
||||
unsubscribeStatus();
|
||||
unsubscribeSettings();
|
||||
};
|
||||
});
|
||||
|
||||
@@ -85,6 +95,13 @@
|
||||
</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}>
|
||||
@@ -162,6 +179,12 @@
|
||||
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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { EVENT_REQUEST_OPEN_P2P_SETTINGS, eventHub } from "@/common/events";
|
||||
import { EVENT_LAYOUT_READY, EVENT_REQUEST_OPEN_P2P_SETTINGS, eventHub } from "@/common/events";
|
||||
import {
|
||||
EVENT_SERVER_STATUS,
|
||||
EVENT_REQUEST_STATUS,
|
||||
@@ -8,12 +8,21 @@
|
||||
EVENT_P2P_REPLICATOR_PROGRESS,
|
||||
type P2PServerInfo,
|
||||
} from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import type { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import type { P2PReplicatorStatus, P2PReplicationReport } from "@/lib/src/replication/trystero/TrysteroReplicator";
|
||||
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;
|
||||
@@ -24,11 +33,22 @@
|
||||
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);
|
||||
@@ -50,6 +70,57 @@
|
||||
}, 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);
|
||||
@@ -77,10 +148,16 @@
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -89,6 +166,7 @@
|
||||
unsubscribeReplicatorStatus();
|
||||
unsubscribeReplicatorProgress();
|
||||
unsubscribeSettings();
|
||||
unsubscribeLayoutReady();
|
||||
};
|
||||
});
|
||||
|
||||
@@ -110,6 +188,113 @@
|
||||
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,
|
||||
@@ -142,6 +327,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -151,6 +349,9 @@
|
||||
}
|
||||
|
||||
function toggleWatch(peerId: string) {
|
||||
if (!canEditP2PSettings()) {
|
||||
return;
|
||||
}
|
||||
if (isWatching(peerId)) {
|
||||
liveSyncReplicator.unwatchPeer(peerId);
|
||||
} else {
|
||||
@@ -175,28 +376,59 @@
|
||||
}
|
||||
|
||||
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 core.services.setting.applyPartial({ P2P_SyncOnReplication: newValue }, true);
|
||||
await updateSelectedP2PRemote({ P2P_SyncOnReplication: newValue });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p2p-container">
|
||||
<div class="pane-header">
|
||||
<h2>P2P Status</h2>
|
||||
<button
|
||||
class="icon-button"
|
||||
onclick={openConnectionSettings}
|
||||
title="Open P2P Setup..."
|
||||
aria-label="Open P2P Setup..."
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<P2PServerStatusCard {liveSyncReplicator} />
|
||||
{#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">
|
||||
@@ -225,6 +457,15 @@
|
||||
<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}
|
||||
@@ -239,9 +480,10 @@
|
||||
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) ? '👁' : '👁🗨'}
|
||||
{isWatching(peer.peerId) ? '🔔' : '🔕'}
|
||||
</button>
|
||||
</div> <div class="decision-row watch-row">
|
||||
<span class="decision-label">SYNC</span>
|
||||
@@ -249,9 +491,10 @@
|
||||
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) ? '🔄' : '🔁'}
|
||||
{isSyncTarget(peer.name) ? '🔗' : '⛓️💥'}
|
||||
</button>
|
||||
</div> {:else}
|
||||
<div class="decision-status">
|
||||
@@ -345,6 +588,37 @@
|
||||
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: 14rem;
|
||||
min-width: 8rem;
|
||||
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;
|
||||
@@ -511,7 +785,7 @@
|
||||
}
|
||||
|
||||
.accepted-row {
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
}
|
||||
|
||||
.decision-label {
|
||||
|
||||
2
src/lib
2
src/lib
Submodule src/lib updated: 07e287c531...36b99354f6
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,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 });
|
||||
|
||||
209
updates.md
209
updates.md
@@ -3,6 +3,50 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025)
|
||||
|
||||
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
|
||||
|
||||
|
||||
## Unreleased
|
||||
|
||||
18th May, 2026
|
||||
|
||||
### Improved
|
||||
- Improved an error verbosity on concurrent processing on start-up process.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix an issue about resuming from background on iOS (#888).
|
||||
|
||||
|
||||
## 0.25.64
|
||||
|
||||
17th May, 2026
|
||||
|
||||
### P2P Status Pane
|
||||
|
||||
- Added active P2P remote selector (combo box) and `+` action to create/select a P2P remote from the P2P setup dialogue.
|
||||
- Added per-peer immediate replication action on accepted peers.
|
||||
- Updated status control icons for clarity:
|
||||
- Replicate now: `🔄` (`⏳` while running)
|
||||
- Watch: `🔔` / `🔕`
|
||||
- Sync target: `🔗` / `⛓️💥`
|
||||
- Added warning state when no active P2P remote is selected.
|
||||
|
||||
### P2P Status Card
|
||||
|
||||
- Added stable Room ID suffix display and placed it above Peer ID for better identification.
|
||||
|
||||
### Non behavioural internal changes
|
||||
|
||||
#### P2P
|
||||
|
||||
- Added `P2P_ActiveRemoteConfigurationId` as a dedicated active remote selection for P2P features, separate from the normal active remote.
|
||||
- Added activation logic for P2P dedicated remote configuration that reflects P2P settings while keeping `remoteType` unchanged.
|
||||
- Added migration support to carry over P2P active remote selection when appropriate.
|
||||
- Added shared Room ID utility functions and applied them across P2P setup and P2P panes.
|
||||
|
||||
#### Tests
|
||||
|
||||
- Added/updated unit test coverage around settings load behaviour for P2P active remote application.
|
||||
|
||||
## 0.25.63
|
||||
|
||||
17th May, 2026
|
||||
@@ -178,170 +222,5 @@ This P2P synchronisation is not compatible with previous versions in terms of co
|
||||
- Add coverage for the test.
|
||||
- Pop-ups are now shown in the web app as well.
|
||||
|
||||
## 0.25.53
|
||||
|
||||
17th March, 2026
|
||||
|
||||
I did wonder whether I should have released a minor version update, but when I actually tested it, compatibility seemed to be intact, so I didn’t. Hmm.
|
||||
|
||||
### Fixed
|
||||
|
||||
#### P2P Synchronisation
|
||||
|
||||
- Fixed flaky timing issues in P2P synchronisation.
|
||||
- No longer unexpected `Unhandled Rejections` during P2P operations (waiting for acceptance).
|
||||
|
||||
#### Journal Sync
|
||||
|
||||
- Fixed an issue where some conflicts cannot be resolved in Journal Sync.
|
||||
- Many minor fixes have been made for better stability and reliability.
|
||||
|
||||
### Tests
|
||||
|
||||
- Rewrite P2P end-to-end tests to use the CLI as a host.
|
||||
|
||||
### CLI
|
||||
|
||||
We have previously developed FileSystem LiveSync and various other components in a separate repository, but updates have been significantly delayed, and we have been plagued by compatibility issues. Now, a CLI tool using the same core logic is emerging. This does not directly manipulate the file system, but it offers a more convenient way of working and can also communicate with Object Storage. We can also resolve conflicts. Please refer to the code in `src/apps/cli` for the [self-hosted-livesync-cli](./src/apps/cli/README.md) for more details.
|
||||
- Add `self-hosted-livesync-cli` to `src/apps/cli` as a headless and dedicated version.
|
||||
- P2P sync and Object Storage are also supported in the CLI.
|
||||
- Yes, we have finally managed to 'get one file'.
|
||||
- Also, no more need for a [LiveSync PeerServer](https://github.com/vrtmrz/livesync-serverpeer) for virtual environments! The CLI can do it.
|
||||
|
||||
- Now binary files are also supported in the CLI.
|
||||
|
||||
### Refactored or internal changes
|
||||
|
||||
- ServiceFileAccessBase now correctly handles the reading of binary files.
|
||||
- HeadlessAPIService now correctly provides the online status (always online) to the plug-in.
|
||||
- Non-worker version of bgWorker now correctly handles some functions.
|
||||
- Separated `ObsidianLiveSyncPlugin` into `ObsidianLiveSyncPlugin` and `LiveSyncBaseCore`.
|
||||
- Now `LiveSyncCore` indicates the type specified version of `LiveSyncBaseCore`.
|
||||
- Referencing `plugin.xxx` has been rewritten to referencing the corresponding service or `core.xxx`.
|
||||
- Offline change scanner and the local database preparation have been separated.
|
||||
- Set default priority for processFileEvent and processSynchroniseResult for the place to add hooks.
|
||||
- ControlService now provides the readiness for processing operations.
|
||||
- DatabaseService is now able to modify database opening options on derived classes.
|
||||
- Now `useOfflineScanner`, `useCheckRemoteSize`, and `useRedFlagFeatures` are set from `main.ts`, instead of `LiveSyncBaseCore`.
|
||||
- Storage Access APIs are now yielding Promises. This is to allow more limited storage platforms to be supported.
|
||||
- Journal Replicator now yields true after the replication is done.
|
||||
|
||||
### R&D
|
||||
|
||||
- Browser-version of Self-hosted LiveSync is now in development. This is not intended for public use now, but I will eventually make it available for testing.
|
||||
- We can see the code in `src/apps/webapp` for the browser version.
|
||||
|
||||
## 0.25.52
|
||||
|
||||
9th March, 2026
|
||||
|
||||
Excuses: Too much `I`.
|
||||
Whilst I had a fever, I could not figure it out at all, but once I felt better, I spotted the problem in about thirty seconds. I apologise for causing you concern. I am grateful for your patience.
|
||||
I would like to devise a mechanism for running simple test scenarios. Now that we have got the Obsidian CLI up and running, it seems the perfect opportunity.
|
||||
|
||||
To improve the bus factor, we really need to organise the source code more thoroughly. Your cooperation and contributions would be greatly appreciated.
|
||||
|
||||
### Fixed
|
||||
|
||||
- No longer unexpected deletion-propagation occurs when the parent directory is not empty (#813).
|
||||
|
||||
### Revert reversions
|
||||
|
||||
- Reverted the reversion of ModuleCheckRemoteSize. Now it is back to the service feature.
|
||||
|
||||
## 0.25.51
|
||||
|
||||
7th March, 2026
|
||||
|
||||
### Reverted
|
||||
|
||||
- Reverted to ModuleRedFlag and ModuleInitializerFile to the previous version because of some unexpected issues. (#813)
|
||||
- I will re-implement them in the future with better design and tests.
|
||||
|
||||
## 0.25.50
|
||||
|
||||
3rd March, 2026
|
||||
|
||||
Note: 0.25.49 has been skipped because of too verbose logging (credentials are logged in verbose level, but I realised that could lead to unexpected exposure on issue reporting). Please bump to 0.25.50 to get the fix if you are on 0.25.49. (No expected behaviour changes except the logging).
|
||||
|
||||
### Fixed
|
||||
|
||||
- No longer deleted files are not clickable in the Global History pane.
|
||||
- Diff view now uses more specific classes (#803).
|
||||
- A message of configuration mismatching slightly added for better understanding.
|
||||
- Now it says `When replication is initiated manually via the command palette or ribbon, a dialogue box will open to address this.` to make it clear that the user can fix the issue by themselves.
|
||||
|
||||
### Refactored
|
||||
|
||||
- `ModuleRedFlag` has been refactored to `serviceFeatures/redFlag` and also tested.
|
||||
- `ModuleInitializerFile` has been refactored to `lib/serviceFeatures/offlineScanner` and also tested.
|
||||
|
||||
## 0.25.48
|
||||
|
||||
2nd March, 2026
|
||||
|
||||
No behavioural changes except unidentified faults. Please report if you find any unexpected behaviour after this update.
|
||||
|
||||
### Refactored
|
||||
|
||||
- Many storage-related functions have been refactored for better maintainability and testability.
|
||||
- Now all platform-specific logics are supplied as adapters, and the core logic has become platform-agnostic.
|
||||
- Quite a number of tests have been added for the core logic, and the platform-specific logics are also tested with mocked adapters.
|
||||
|
||||
## 0.25.47
|
||||
|
||||
27th February, 2026
|
||||
|
||||
Phew, the financial year is still not over yet, but I have got some time to work on the plug-in again!
|
||||
|
||||
### Fixed and refactored
|
||||
|
||||
- Fixed the inexplicable behaviour when retrieving chunks from the network.
|
||||
- The chunk manager has been layered to be responsible for its own areas and duties. e.g., `DatabaseWriteLayer`, `DatabaseReadLayer`, `NetworkLayer`, `CacheLayer`, and `ArrivalWaitLayer`.
|
||||
- All layers have been tested now!
|
||||
- `LayeredChunkManager` has been implemented to manage these layers. Also tested.
|
||||
- `EntryManager` has been mostly rewritten and also tested.
|
||||
|
||||
- Now we can configure `Never warn` for remote storage size notification again.
|
||||
|
||||
### Tests
|
||||
|
||||
- The following test has been added:
|
||||
- `ConflictManager`.
|
||||
|
||||
## 0.25.46
|
||||
|
||||
26th February, 2026
|
||||
|
||||
### Fixed
|
||||
|
||||
- Unexpected errors no longer occurred when the plug-in was unloaded.
|
||||
- Hidden File Sync now respects selectors.
|
||||
- Registering protocol-handlers now works safely without causing unexpected errors.
|
||||
|
||||
### Refactored
|
||||
|
||||
- `ModuleCheckRemoteSize` has been ported to a serviceFeature, and tests have also been added.
|
||||
- Some unnecessary things have been removed.
|
||||
- LiveSyncManagers has now explicit dependencies.
|
||||
- LiveSyncLocalDB is now responsible for LiveSyncManagers, not accepting the managers as dependencies.
|
||||
- This is to avoid circular dependencies and clarify the ownership of the managers.
|
||||
- ChangeManager has been refactored. This had a potential issue, so something had been fixed, possibly.
|
||||
- Some tests have been ported from Deno's test runner to Vitest to accumulate coverage.
|
||||
|
||||
## 0.25.45
|
||||
|
||||
25th February, 2026
|
||||
|
||||
As a result of recent refactoring, we are able to write tests more easily now!
|
||||
|
||||
### Refactored
|
||||
|
||||
- `ModuleTargetFilter`, which was responsible for checking if a file is a target file, has been ported to a serviceFeature.
|
||||
- And also tests have been added. The middleware-style-power.
|
||||
- `ModuleObsidianAPI` has been removed and implemented in `APIService` and `RemoteService`.
|
||||
- Now `APIService` is responsible for the network-online-status, not `databaseService.managers.networkManager`.
|
||||
|
||||
|
||||
Full notes are in
|
||||
[updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md).
|
||||
|
||||
166
updates_old.md
166
updates_old.md
@@ -4,6 +4,172 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025)
|
||||
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
|
||||
|
||||
|
||||
## 0.25.64
|
||||
|
||||
17th May, 2026
|
||||
|
||||
### P2P Status Pane
|
||||
|
||||
- Added active P2P remote selector (combo box) and `+` action to create/select a P2P remote from the P2P setup dialogue.
|
||||
- Added per-peer immediate replication action on accepted peers.
|
||||
- Updated status control icons for clarity:
|
||||
- Replicate now: `🔄` (`⏳` while running)
|
||||
- Watch: `🔔` / `🔕`
|
||||
- Sync target: `🔗` / `⛓️💥`
|
||||
- Added warning state when no active P2P remote is selected.
|
||||
|
||||
### P2P Status Card
|
||||
|
||||
- Added stable Room ID suffix display and placed it above Peer ID for better identification.
|
||||
|
||||
### Non behavioural internal changes
|
||||
|
||||
#### P2P
|
||||
|
||||
- Added `P2P_ActiveRemoteConfigurationId` as a dedicated active remote selection for P2P features, separate from the normal active remote.
|
||||
- Added activation logic for P2P dedicated remote configuration that reflects P2P settings while keeping `remoteType` unchanged.
|
||||
- Added migration support to carry over P2P active remote selection when appropriate.
|
||||
- Added shared Room ID utility functions and applied them across P2P setup and P2P panes.
|
||||
|
||||
#### Tests
|
||||
|
||||
- Added/updated unit test coverage around settings load behaviour for P2P active remote application.
|
||||
|
||||
## 0.25.63
|
||||
|
||||
17th May, 2026
|
||||
|
||||
### Fixed
|
||||
- The issue which cannot synchronise in Only-P2P mode has been fixed.
|
||||
- Fixed an issue where "Failed to connect to the remote server" was shown during the redFlag rebuild flow when P2P was the primary remote type. Remote configuration fetch is now skipped for P2P.
|
||||
|
||||
### P2P Replication UI Improvements
|
||||
- Brand-new P2P Server Status pane has been added to provide real-time visibility into your connection status and peer network.
|
||||
- For detailed instructions on using the new P2P features, please refer to the updated [User Guide: Peer-to-Peer Synchronisation (2026 Edition)](./docs/p2p_sync_updates_2026.md).
|
||||
- Now `Replicate` button or ribbon icon opens a redesigned interactive replication dialogue that performs smart bidirectional sync with a single click.
|
||||
- The vault rebuild flow (`replicateAllFromServer`) now opens the redesigned P2P Replication modal instead of a plain text selection dialogue, providing a consistent UI experience.
|
||||
|
||||
## 0.25.62
|
||||
|
||||
14th May, 2026
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue where a connection could not be established when attempting to connect to a brand-new remote database without going through the set-up wizard or configuration checking (#660).
|
||||
|
||||
## 0.25.61
|
||||
|
||||
13th May, 2026
|
||||
|
||||
Reviews have started on the Obsidian Community, haven't they? It was quite a struggle, what with having to fix the outdated ESLint.
|
||||
I am a bit nervous, but it is far better than just plodding along aimlessly, so let us get on with it. If you spot any issues, please let me know straight away.
|
||||
|
||||
From now on, I am avoiding committing directly to the main branch. This is because you lots have all been sending so much PRs. I wanted to keep things harmonious.
|
||||
That said, I am still not used to rebasing, so there are some parts where the commit history is a right mess. I will work on improving that.
|
||||
|
||||
### Improved
|
||||
|
||||
- P2P synchronisation has been made more robust
|
||||
Now the foundation for P2P synchronisation has been rewritten, and the unit tests have been added. The foundation has been separated into the transport layer, signalling-and-connection layer, and, an RPC layers. And each layer has been unit-tested. As the result, the P2P synchronisation now uses the robust shim that uses RPC-ed PouchDB synchronisation in contrast to previous implementation.
|
||||
This P2P synchronisation is not compatible with previous versions in terms of connectivity. All devices must be updated.
|
||||
|
||||
### Fixed
|
||||
|
||||
- No longer baffling errors occur when setting-update is triggered during the early stage of initialisation.
|
||||
- Network error notice pop-ups are now suppressed when 'NetworkWarningStyle' is set to 'Hidden'. (Thank you so much @SeleiXi!)
|
||||
|
||||
### New features
|
||||
|
||||
- Diff navigation buttons have been added to the diff view, making it easier to move between differences. (Thank you so much @SeleiXi! #871)
|
||||
|
||||
### Translations
|
||||
|
||||
- Chinese (Simplified) translations for settings and the Setup Wizard have been added. (Thank you so much @zombiek731!)
|
||||
- Common UI controls and signal words are now localised into Chinese (Simplified). (Thank you so much @zombiek731!)
|
||||
- i18n runtime behaviour and locale coverage have been improved. (Thank you so much @52sanmao!)
|
||||
|
||||
### CLI
|
||||
|
||||
#### New features
|
||||
|
||||
- Daemon synchronisation is now supported. (Thank you so much @andrewleech! #843)
|
||||
- `HeadlessConfirm` has been implemented with sensible defaults, enabling unattended operation in headless environments. (Thank you so much @andrewleech!)
|
||||
- The CLI onboarding experience has been improved. (Thank you so much @OriBoharon! #872)
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Sub-millisecond CLI mtimes are now truncated to prevent mobile crash. (Thank you so much @brian-spackman! #893)
|
||||
|
||||
## 0.25.60
|
||||
|
||||
29th April, 2026
|
||||
|
||||
### Fixed
|
||||
|
||||
- Now larger settings can be exported and imported via QR code without issues. (#595)
|
||||
- When the settings data exceeds the QR code capacity, it is now split into multiple QR codes.
|
||||
- These QR codes are reassembled by the aggregator page, which collects the split data and reconstructs the original settings.
|
||||
- Aggregator page is available at `https://vrtmrz.github.io/obsidian-livesync/aggregator.html`, and this file is also included in the repository.
|
||||
- We will not send the settings data to any server. The QR code data is generated and processed entirely on the client side, ensuring that your settings remain private and secure. HOWEVER, please be careful your network environment.
|
||||
- Fixed some errors during serialisation and deserialisation of the settings, which caused issues in some cases when importing/exporting settings via QR code.
|
||||
|
||||
### Fixed (CLI)
|
||||
|
||||
- `ls` and `mirror` commands now provide informative feedback when no documents are found or filters skip all files, resolving the issue where they would exit silently (#860).
|
||||
- Improved the clarity of CLI command logs by including the total count of processed items.
|
||||
- The command-line argument `vault` has been renamed to a more appropriate name, `databaseDir`.
|
||||
- The `mirror` command now accepts a `vault` directory, which specifies the location where the actual files are stored. For compatibility reasons, the previous behaviour is still supported.
|
||||
|
||||
## 0.25.59
|
||||
|
||||
### Fixed
|
||||
|
||||
- No longer Setup-wizard drops username and password silently. (#865)
|
||||
- Thank you so much for @koteitan !
|
||||
- Setup URI is now correctly imported (#859).
|
||||
- Also thank you so much for @koteitan !
|
||||
|
||||
### Improved
|
||||
|
||||
- now French translation is added by @foXaCe ! Thank you so much!
|
||||
|
||||
## 0.25.58
|
||||
|
||||
### Fixed
|
||||
|
||||
- No longer credentials are broken during object storage configuration (related: #852).
|
||||
- Fixed a worker-side recursion issue that could raise `Maximum call stack size exceeded` during chunk splitting (related: #855).
|
||||
- Improved background worker crash cleanup so pending split/encryption tasks are released cleanly instead of being left in a waiting state (related: #855).
|
||||
- On start-up, the selected remote configuration is now applied to runtime connection fields as well, reducing intermittent authentication failures caused by stale runtime settings (related: #855).
|
||||
- Issue report generation now redacts `remoteConfigurations` connection strings and keeps only the scheme (e.g. `sls+https://`), so credentials are not exposed in reports.
|
||||
- Hidden file JSON conflicts no longer keep re-opening and dismissing the merge dialogue before we can act, which fixes persistent unresolvable `data.json` conflicts in plug-in settings sync (related: #850).
|
||||
|
||||
## 0.25.57
|
||||
|
||||
9th April, 2026
|
||||
|
||||
- Packing a batch during the journal sync now continues even if the batch contains no items to upload.
|
||||
- No unexpected error (about a replicator) during the early stage of initialisation.
|
||||
- Now error messages are kept hidden if the show status inside the editor is disabled (related: #829).
|
||||
- Fixed an issue where devices could no longer upload after another device performed 'Fresh Start Wipe' and 'Overwrite remote' in Object Storage mode (#848).
|
||||
- Each device's local deduplication caches (`knownIDs`, `sentIDs`, `receivedFiles`, `sentFiles`) now track the remote journal epoch (derived from the encryption parameters stored on the remote).
|
||||
- When the epoch changes, the plugin verifies whether the device's last uploaded file still exists on the remote. If the file is gone, it confirms a remote wipe and automatically clears the stale caches. If the file is still present (e.g. a protocol upgrade without a wipe), the caches are preserved, and only the epoch is updated. This means normal upgrades never cause unnecessary re-processing.
|
||||
|
||||
### Translations
|
||||
|
||||
- Russian translation has been added! Thank you so much for the contribution, @vipka1n! (#845)
|
||||
|
||||
### New features
|
||||
|
||||
- Now we can configure multiple Remote Databases of the same type, e.g, multiple CouchDBs or S3 remotes.
|
||||
- A user interface for managing multiple remote databases has been added to the settings dialogue. I think no explanation is needed for the UI, but please let me know if you have any questions.
|
||||
- We can switch between multiple Remote Databases in the settings dialogue.
|
||||
|
||||
### CLI
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Replication progress is now correctly saved and restored in the CLI (related: #846).
|
||||
|
||||
## ~~0.25.55~~ 0.25.56
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@ const targetVersion = process.env.npm_package_version;
|
||||
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||
const { minAppVersion } = manifest;
|
||||
manifest.version = targetVersion;
|
||||
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||
writeFileSync("manifest.json", JSON.stringify(manifest, null, 4));
|
||||
|
||||
// update versions.json with target version and minAppVersion from manifest.json
|
||||
// but only if the target version is not already in versions.json
|
||||
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
|
||||
if (!Object.values(versions).includes(minAppVersion)) {
|
||||
versions[targetVersion] = minAppVersion;
|
||||
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
|
||||
writeFileSync('versions.json', JSON.stringify(versions, null, 4));
|
||||
}
|
||||
Reference in New Issue
Block a user