mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-28 06:17:06 +00:00
feat: configure WebDAV journal remotes
This commit is contained in:
@@ -46,7 +46,6 @@ function serializeRemoteConfiguration(settings: ObsidianLiveSyncSettings): strin
|
||||
const configuration = defaultRemoteProviderRegistry.configurationFromSettings(type, settings);
|
||||
return defaultRemoteProviderRegistry.serialise(configuration);
|
||||
}
|
||||
|
||||
function setEmojiButton(button: ButtonComponent, emoji: string, tooltip: string) {
|
||||
button.setButtonText(emoji);
|
||||
button.setTooltip(tooltip, { delay: 10, placement: "top" });
|
||||
@@ -259,7 +258,7 @@ export function paneRemoteConfig(
|
||||
for (const config of Object.values(configs)) {
|
||||
const row = new Setting(listContainer)
|
||||
.setName(config.name)
|
||||
.setDesc(config.uri.split("@").pop() || ""); // Show host part for privacy
|
||||
.setDesc(describeRemoteConfiguration(config.uri));
|
||||
|
||||
if (config.id === this.editingSettings.activeConfigurationId) {
|
||||
row.nameEl.addClass("sls-active-remote-name");
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { pickBucketSyncSettings, pickCouchDBSyncSettings, pickP2PSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import {
|
||||
pickBucketSyncSettings,
|
||||
pickCouchDBSyncSettings,
|
||||
pickP2PSyncSettings,
|
||||
pickWebDAVSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
// Keep the setting dialogue buffer aligned with the current core settings before persisting other dirty keys.
|
||||
@@ -11,6 +16,7 @@ export function syncActivatedRemoteSettings(
|
||||
remoteType: source.remoteType,
|
||||
activeConfigurationId: source.activeConfigurationId,
|
||||
...pickBucketSyncSettings(source),
|
||||
...pickWebDAVSyncSettings(source),
|
||||
...pickCouchDBSyncSettings(source),
|
||||
...pickP2PSyncSettings(source),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_WEBDAV,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { syncActivatedRemoteSettings } from "./remoteConfigBuffer";
|
||||
|
||||
describe("syncActivatedRemoteSettings", () => {
|
||||
@@ -86,4 +91,34 @@ describe("syncActivatedRemoteSettings", () => {
|
||||
expect(target.couchDB_PASSWORD).toBe("current-pass");
|
||||
expect(target.couchDB_DBNAME).toBe("current-db");
|
||||
});
|
||||
|
||||
it("should copy the active WebDAV connection and Adaptive protocol into the editing buffer", () => {
|
||||
const target = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
activeConfigurationId: "old-remote",
|
||||
webDAVactiveConnectionURI: "sls+webdav://stale.invalid/",
|
||||
expectedRepositoryId: "",
|
||||
journalFormat: "opaque-v1" as const,
|
||||
packReadPolicy: "whole-pack" as const,
|
||||
};
|
||||
const source = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_WEBDAV,
|
||||
activeConfigurationId: "remote-webdav",
|
||||
webDAVactiveConnectionURI: "sls+webdav://alice:secret@dav.example/dav?prefix=notes%2F",
|
||||
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
journalFormat: "adaptive-v1" as const,
|
||||
packReadPolicy: "range" as const,
|
||||
};
|
||||
|
||||
syncActivatedRemoteSettings(target, source);
|
||||
|
||||
expect(target.remoteType).toBe(REMOTE_WEBDAV);
|
||||
expect(target.activeConfigurationId).toBe("remote-webdav");
|
||||
expect(target.webDAVactiveConnectionURI).toBe(source.webDAVactiveConnectionURI);
|
||||
expect(target.expectedRepositoryId).toBe(source.expectedRepositoryId);
|
||||
expect(target.journalFormat).toBe("adaptive-v1");
|
||||
expect(target.packReadPolicy).toBe("range");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
REMOTE_WEBDAV,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
ConnectionStringParser,
|
||||
type RemoteConfigurationResult,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import { parseWebDAVConnectionURI } from "@vrtmrz/livesync-commonlib/journal-storage";
|
||||
|
||||
export type ConfigurableRemoteType =
|
||||
| typeof REMOTE_COUCHDB
|
||||
| typeof REMOTE_MINIO
|
||||
| typeof REMOTE_WEBDAV
|
||||
| typeof REMOTE_P2P;
|
||||
|
||||
function publicOrigin(uri: string): string {
|
||||
const url = new URL(uri);
|
||||
return `${url.protocol}//${url.host}`;
|
||||
}
|
||||
|
||||
export function serializeRemoteConfiguration(settings: ObsidianLiveSyncSettings): string {
|
||||
switch (settings.remoteType) {
|
||||
case REMOTE_COUCHDB:
|
||||
return ConnectionStringParser.serialize({ type: "couchdb", settings });
|
||||
case REMOTE_MINIO:
|
||||
return ConnectionStringParser.serialize({ type: "s3", settings });
|
||||
case REMOTE_WEBDAV:
|
||||
return ConnectionStringParser.serialize({ type: "webdav", settings });
|
||||
case REMOTE_P2P:
|
||||
return ConnectionStringParser.serialize({ type: "p2p", settings });
|
||||
default:
|
||||
throw new Error("Unsupported remote type");
|
||||
}
|
||||
}
|
||||
|
||||
export function remoteTypeForRemoteConfiguration(parsed: RemoteConfigurationResult): ConfigurableRemoteType {
|
||||
switch (parsed.type) {
|
||||
case "couchdb":
|
||||
return REMOTE_COUCHDB;
|
||||
case "s3":
|
||||
return REMOTE_MINIO;
|
||||
case "webdav":
|
||||
return REMOTE_WEBDAV;
|
||||
case "p2p":
|
||||
return REMOTE_P2P;
|
||||
}
|
||||
}
|
||||
|
||||
export function suggestRemoteConfigurationName(parsed: RemoteConfigurationResult): string {
|
||||
if (parsed.type === "couchdb") {
|
||||
try {
|
||||
const url = new URL(parsed.settings.couchDB_URI);
|
||||
return `CouchDB ${url.host}`;
|
||||
} catch {
|
||||
return "Imported CouchDB";
|
||||
}
|
||||
}
|
||||
if (parsed.type === "s3") {
|
||||
return `S3 ${parsed.settings.bucket || parsed.settings.endpoint}`;
|
||||
}
|
||||
if (parsed.type === "webdav") {
|
||||
try {
|
||||
const endpoint = new URL(parseWebDAVConnectionURI(parsed.settings.webDAVactiveConnectionURI).endpoint);
|
||||
return `WebDAV ${endpoint.host}`;
|
||||
} catch {
|
||||
return "Imported WebDAV";
|
||||
}
|
||||
}
|
||||
return `P2P ${parsed.settings.P2P_roomID || "Remote"}`;
|
||||
}
|
||||
|
||||
export function describeRemoteConfiguration(uri: string): string {
|
||||
try {
|
||||
const parsed = ConnectionStringParser.parse(uri);
|
||||
if (parsed.type === "couchdb") return publicOrigin(parsed.settings.couchDB_URI);
|
||||
if (parsed.type === "s3") return publicOrigin(parsed.settings.endpoint);
|
||||
if (parsed.type === "webdav") {
|
||||
return publicOrigin(parseWebDAVConnectionURI(parsed.settings.webDAVactiveConnectionURI).endpoint);
|
||||
}
|
||||
return "P2P";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_WEBDAV,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import {
|
||||
describeRemoteConfiguration,
|
||||
remoteTypeForRemoteConfiguration,
|
||||
serializeRemoteConfiguration,
|
||||
suggestRemoteConfigurationName,
|
||||
} from "./remoteConfigurationEditor.ts";
|
||||
|
||||
describe("remote configuration editor helpers", () => {
|
||||
it("maps, names, and serialises an Adaptive WebDAV profile", () => {
|
||||
const repositoryId = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_WEBDAV,
|
||||
webDAVactiveConnectionURI:
|
||||
"sls+webdav://alice:secret@dav.example/remote.php/dav/files/alice?prefix=notes%2F",
|
||||
expectedRepositoryId: repositoryId,
|
||||
journalFormat: "adaptive-v1" as const,
|
||||
packReadPolicy: "range" as const,
|
||||
} as ObsidianLiveSyncSettings;
|
||||
|
||||
const uri = serializeRemoteConfiguration(settings);
|
||||
const parsed = ConnectionStringParser.parse(uri);
|
||||
|
||||
expect(parsed.type).toBe("webdav");
|
||||
expect(remoteTypeForRemoteConfiguration(parsed)).toBe(REMOTE_WEBDAV);
|
||||
expect(suggestRemoteConfigurationName(parsed)).toBe("WebDAV dav.example");
|
||||
expect(uri).toContain("journalFormat=adaptive-v1");
|
||||
expect(uri).toContain("packReadPolicy=range");
|
||||
expect(uri).toContain(`expectedRepositoryId=${repositoryId}`);
|
||||
});
|
||||
|
||||
it("does not expose WebDAV credentials or custom headers in the saved-connection description", () => {
|
||||
const uri =
|
||||
"sls+webdav://alice:secret@dav.example/remote.php/dav/files/alice" +
|
||||
"?prefix=notes%2F&headers=Authorization%3A+Bearer+private-token";
|
||||
|
||||
const description = describeRemoteConfiguration(uri);
|
||||
|
||||
expect(description).toBe("https://dav.example");
|
||||
expect(description).not.toContain("alice");
|
||||
expect(description).not.toContain("secret");
|
||||
expect(description).not.toContain("private-token");
|
||||
});
|
||||
});
|
||||
@@ -284,6 +284,21 @@ export class SetupManager extends AbstractModule {
|
||||
return await this.onRemoteManualSetup("s3", userMode, currentSetting, activate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles manual setup for WebDAV Journal storage.
|
||||
* @param userMode
|
||||
* @param currentSetting
|
||||
* @param activate Whether to activate WebDAV as the main remote type
|
||||
* @returns Promise that resolves to true if setup completed successfully, false otherwise
|
||||
*/
|
||||
async onWebDAVManualSetup(
|
||||
userMode: UserMode,
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
activate = true
|
||||
): Promise<boolean> {
|
||||
return await this.onRemoteManualSetup("webdav", userMode, currentSetting, activate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles manual setup for P2P
|
||||
* @param userMode
|
||||
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
REMOTE_WEBDAV,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { SettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
|
||||
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
|
||||
vi.mock("./SetupWizard/dialogs/Intro.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SelectMethodNewUser.svelte", () => ({ default: {} }));
|
||||
@@ -21,6 +23,7 @@ vi.mock("./SetupWizard/dialogs/OutroAskUserMode.svelte", () => ({ default: {} })
|
||||
vi.mock("./SetupWizard/dialogs/SetupRemote.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SetupRemoteCouchDB.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SetupRemoteBucket.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SetupRemoteWebDAV.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SetupRemoteP2P.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SetupRemoteE2EE.svelte", () => ({ default: {} }));
|
||||
|
||||
@@ -306,6 +309,44 @@ describe("SetupManager", () => {
|
||||
expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves Adaptive WebDAV fields imported through a Setup URI profile", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
const repositoryId = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
const imported = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
remoteConfigurations: {
|
||||
webdav: {
|
||||
id: "webdav",
|
||||
name: "WebDAV notes",
|
||||
uri:
|
||||
"sls+webdav://alice:secret@dav.example/dav?prefix=notes%2F" +
|
||||
`&journalFormat=adaptive-v1&expectedRepositoryId=${repositoryId}&packReadPolicy=range`,
|
||||
isEncrypted: false,
|
||||
},
|
||||
},
|
||||
activeConfigurationId: "webdav",
|
||||
} as ObsidianLiveSyncSettings;
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce(imported)
|
||||
.mockResolvedValueOnce("compatible-existing-user");
|
||||
|
||||
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://webdav-settings");
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(current.activeConfigurationId).toBe("webdav");
|
||||
const parsed = ConnectionStringParser.parse(current.remoteConfigurations.webdav.uri);
|
||||
expect(parsed).toMatchObject({
|
||||
type: "webdav",
|
||||
settings: {
|
||||
webDAVactiveConnectionURI: "sls+webdav://alice:secret@dav.example/dav?prefix=notes%2F",
|
||||
expectedRepositoryId: repositoryId,
|
||||
journalFormat: "adaptive-v1",
|
||||
packReadPolicy: "range",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("adds and activates a manually configured CouchDB without replacing existing profiles", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
@@ -516,6 +557,62 @@ describe("SetupManager", () => {
|
||||
expect(activeProfile?.uri).not.toContain("expectedRepositoryId=");
|
||||
});
|
||||
|
||||
it("adds and activates a manually configured WebDAV profile without replacing existing profiles", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
...setting.currentSettings(),
|
||||
isConfigured: true,
|
||||
remoteConfigurations: {
|
||||
existing: {
|
||||
id: "existing",
|
||||
name: "Existing remote",
|
||||
uri: "sls+http://old:secret@old.example/?db=old",
|
||||
isEncrypted: false,
|
||||
},
|
||||
},
|
||||
activeConfigurationId: "existing",
|
||||
};
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce({
|
||||
webDAVactiveConnectionURI:
|
||||
"sls+webdav://alice:secret@dav.example/remote.php/dav/files/alice?prefix=notes%2F",
|
||||
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
journalFormat: "adaptive-v1",
|
||||
packReadPolicy: "range",
|
||||
})
|
||||
.mockResolvedValueOnce(true);
|
||||
|
||||
await manager.onWebDAVManualSetup(UserMode.ExistingUser, setting.currentSettings());
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(current.remoteType).toBe(REMOTE_WEBDAV);
|
||||
expect(current.remoteConfigurations.existing).toBeDefined();
|
||||
expect(Object.keys(current.remoteConfigurations)).toHaveLength(2);
|
||||
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
|
||||
expect(activeProfile?.name).toBe("WebDAV dav.example");
|
||||
expect(activeProfile?.uri).toContain("sls+webdav://alice:secret@dav.example");
|
||||
expect(activeProfile?.uri).toContain("journalFormat=adaptive-v1");
|
||||
expect(activeProfile?.uri).toContain("packReadPolicy=range");
|
||||
expect(activeProfile?.uri).toContain("expectedRepositoryId=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[UserMode.NewUser, "onboarding"],
|
||||
[UserMode.ExistingUser, "onboarding"],
|
||||
[UserMode.Update, "settings"],
|
||||
] as const)("passes the %s WebDAV verification policy to the manual setup dialogue", async (userMode, mode) => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
|
||||
vi.spyOn(manager, "onOnboard").mockResolvedValue(false);
|
||||
|
||||
await manager.onWebDAVManualSetup(userMode, setting.currentSettings());
|
||||
|
||||
expect(dialogManager.openWithExplicitCancel).toHaveBeenCalledWith(expect.anything(), {
|
||||
settings: setting.currentSettings(),
|
||||
mode,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates and selects a P2P profile during fresh manual onboarding", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
|
||||
@@ -5,11 +5,14 @@ import { $msg as translateMessage } from "@/common/translation";
|
||||
import SetupRemoteBucket from "./dialogs/SetupRemoteBucket.svelte";
|
||||
import SetupRemoteCouchDB from "./dialogs/SetupRemoteCouchDB.svelte";
|
||||
import SetupRemoteP2P from "./dialogs/SetupRemoteP2P.svelte";
|
||||
import SetupRemoteWebDAV from "./dialogs/SetupRemoteWebDAV.svelte";
|
||||
import type {
|
||||
SetupRemoteBucketResultType,
|
||||
SetupRemoteCouchDBInitialData,
|
||||
SetupRemoteCouchDBResultType,
|
||||
SetupRemoteP2PResultType,
|
||||
SetupRemoteWebDAVInitialData,
|
||||
SetupRemoteWebDAVResultType,
|
||||
} from "./dialogs/setupDialogTypes";
|
||||
import { RemoteSetupRegistry, type RemoteSetupProviderDescriptor } from "./RemoteSetupRegistry";
|
||||
|
||||
@@ -91,10 +94,38 @@ export function useP2PRemoteSetup(
|
||||
return registry.register(descriptor);
|
||||
}
|
||||
|
||||
export function useWebDAVRemoteSetup(
|
||||
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
|
||||
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
|
||||
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"webdav">> = {
|
||||
type: "webdav",
|
||||
choice: () => ({
|
||||
title: translateMessage("WebDAV Journal"),
|
||||
description: translateMessage(
|
||||
"Store Journal data in a dedicated WebDAV collection. Adaptive mode is experimental and requires an endpoint safety check."
|
||||
),
|
||||
proceedTitle: translateMessage("Continue to WebDAV setup"),
|
||||
}),
|
||||
open: async ({ dialogManager, intent, settings }) => {
|
||||
const result = await dialogManager.openWithExplicitCancel<
|
||||
SetupRemoteWebDAVResultType,
|
||||
SetupRemoteWebDAVInitialData
|
||||
>(SetupRemoteWebDAV, {
|
||||
settings,
|
||||
mode: intent === "settings" ? "settings" : "onboarding",
|
||||
});
|
||||
return result === "cancelled" ? result : { type: "webdav", settings: result };
|
||||
},
|
||||
};
|
||||
assertSemanticProvider(descriptor.type);
|
||||
return registry.register(descriptor);
|
||||
}
|
||||
|
||||
export function createBuiltInRemoteSetupRegistry(): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
|
||||
const registry = new RemoteSetupRegistry<BuiltInRemoteConfiguration>();
|
||||
useCouchDBRemoteSetup(registry);
|
||||
useS3RemoteSetup(registry);
|
||||
useWebDAVRemoteSetup(registry);
|
||||
useP2PRemoteSetup(registry);
|
||||
return registry;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
|
||||
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
|
||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@/modules/services/LiveSyncUI/components/ExtraItems.svelte";
|
||||
import InputRow from "@/modules/services/LiveSyncUI/components/InputRow.svelte";
|
||||
import Password from "@/modules/services/LiveSyncUI/components/Password.svelte";
|
||||
import { getDialogContext, type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
PREFERRED_JOURNAL_SYNC,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
REMOTE_WEBDAV,
|
||||
isJournalStorageConnectionInspector,
|
||||
type JournalStorageConnectivityResult,
|
||||
type WebDAVSyncSetting,
|
||||
} from "@vrtmrz/livesync-commonlib/journal-storage";
|
||||
import {
|
||||
TYPE_CANCELLED,
|
||||
type SetupRemoteWebDAVInitialData,
|
||||
type SetupRemoteWebDAVResultType,
|
||||
type WebDAVSetupMode,
|
||||
} from "./setupDialogTypes";
|
||||
import {
|
||||
summariseAdaptiveCapabilityInspection,
|
||||
webDAVJournalFormFromSettings,
|
||||
webDAVSyncSettingsFromForm,
|
||||
type WebDAVJournalForm,
|
||||
} from "./webDAVJournalSettings";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
let syncSetting = $state<WebDAVJournalForm>(
|
||||
webDAVJournalFormFromSettings({
|
||||
webDAVactiveConnectionURI: "",
|
||||
expectedRepositoryId: "",
|
||||
journalFormat: "opaque-v1",
|
||||
packReadPolicy: "whole-pack",
|
||||
})
|
||||
);
|
||||
let setupMode = $state<WebDAVSetupMode>("settings");
|
||||
let error = $state("");
|
||||
let processing = $state(false);
|
||||
let inspection = $state<JournalStorageConnectivityResult | undefined>();
|
||||
let inspectionFingerprint = $state("");
|
||||
let inspectedSettings = $state<WebDAVSyncSetting | undefined>();
|
||||
|
||||
type Props = GuestDialogProps<SetupRemoteWebDAVResultType, SetupRemoteWebDAVInitialData>;
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
const context = getDialogContext();
|
||||
|
||||
onMount(() => {
|
||||
const initialData = getInitialData?.();
|
||||
if (!initialData) return;
|
||||
setupMode = initialData.mode;
|
||||
try {
|
||||
Object.assign(syncSetting, webDAVJournalFormFromSettings(initialData.settings));
|
||||
} catch (ex) {
|
||||
error = translateMessage("Invalid WebDAV settings: ${REASON}", {
|
||||
REASON: ex instanceof Error ? ex.message : `${ex}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const isAdaptive = $derived(syncSetting.journalFormat === "adaptive-v1");
|
||||
const isEndpointInsecure = $derived.by(() => syncSetting.endpoint.trim().toLowerCase().startsWith("http://"));
|
||||
const isEndpointValid = $derived.by(() => {
|
||||
try {
|
||||
const endpoint = new URL(syncSetting.endpoint.trim());
|
||||
return (
|
||||
(endpoint.protocol === "http:" || endpoint.protocol === "https:") &&
|
||||
endpoint.search === "" &&
|
||||
endpoint.hash === ""
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const isEndpointInvalid = $derived(syncSetting.endpoint.trim() !== "" && !isEndpointValid);
|
||||
const formFingerprint = $derived.by(() => JSON.stringify(syncSetting));
|
||||
const inspectionIsCurrent = $derived(
|
||||
inspection !== undefined && inspectionFingerprint !== "" && inspectionFingerprint === formFingerprint
|
||||
);
|
||||
const adaptiveSummary = $derived.by(() => {
|
||||
if (!inspectionIsCurrent || !inspection?.adaptiveCapabilities) return undefined;
|
||||
return summariseAdaptiveCapabilityInspection(inspection.adaptiveCapabilities);
|
||||
});
|
||||
|
||||
function generateSetting(webDAVSettings: WebDAVSyncSetting): ObsidianLiveSyncSettings {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...PREFERRED_JOURNAL_SYNC,
|
||||
remoteType: REMOTE_WEBDAV,
|
||||
...webDAVSettings,
|
||||
};
|
||||
}
|
||||
|
||||
function explainUnavailable(result: JournalStorageConnectivityResult): string {
|
||||
if (
|
||||
result.remoteFormat !== undefined &&
|
||||
result.remoteFormat !== "empty" &&
|
||||
result.remoteFormat !== syncSetting.journalFormat
|
||||
) {
|
||||
return translateMessage(
|
||||
"The remote contains ${REMOTE_FORMAT} data, but this profile selects ${SELECTED_FORMAT}. Rebuild the remote or restore the matching format.",
|
||||
{
|
||||
REMOTE_FORMAT: result.remoteFormat,
|
||||
SELECTED_FORMAT: syncSetting.journalFormat,
|
||||
}
|
||||
);
|
||||
}
|
||||
return translateMessage("The selected WebDAV Journal policy is not supported by this endpoint.");
|
||||
}
|
||||
|
||||
async function inspectConnection(trialRemoteSetting: ObsidianLiveSyncSettings, testedFingerprint: string) {
|
||||
const replicator = await context.services.replicator.getNewReplicator(trialRemoteSetting);
|
||||
if (!replicator) {
|
||||
throw new Error(translateMessage("Failed to create replicator instance."));
|
||||
}
|
||||
if (!isJournalStorageConnectionInspector(replicator)) {
|
||||
throw new Error(translateMessage("This build cannot inspect WebDAV Journal capabilities."));
|
||||
}
|
||||
const result = await replicator.inspectJournalStorageConnection(trialRemoteSetting);
|
||||
inspection = result;
|
||||
inspectionFingerprint = testedFingerprint;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function checkConnection() {
|
||||
error = "";
|
||||
inspection = undefined;
|
||||
inspectionFingerprint = "";
|
||||
inspectedSettings = undefined;
|
||||
processing = true;
|
||||
try {
|
||||
const testedFingerprint = formFingerprint;
|
||||
const candidate = webDAVSyncSettingsFromForm(syncSetting);
|
||||
const trialRemoteSetting = generateSetting(candidate);
|
||||
const result = await inspectConnection(trialRemoteSetting, testedFingerprint);
|
||||
if (testedFingerprint !== formFingerprint) return;
|
||||
if (!result.available) {
|
||||
error = explainUnavailable(result);
|
||||
return;
|
||||
}
|
||||
inspectedSettings = candidate;
|
||||
} catch (ex) {
|
||||
error = translateMessage("Error during connection test: ${reason}", {
|
||||
reason: ex instanceof Error ? ex.message : `${ex}`,
|
||||
});
|
||||
} finally {
|
||||
processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function commitVerified() {
|
||||
if (!inspectionIsCurrent || !inspection?.available || !inspectedSettings) return;
|
||||
setResult(inspectedSettings);
|
||||
}
|
||||
|
||||
function commit() {
|
||||
error = "";
|
||||
try {
|
||||
setResult(webDAVSyncSettingsFromForm(syncSetting));
|
||||
} catch (ex) {
|
||||
error = translateMessage("Invalid WebDAV settings: ${REASON}", {
|
||||
REASON: ex instanceof Error ? ex.message : `${ex}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DialogHeader title={translateMessage("WebDAV Journal Configuration")} />
|
||||
<Guidance>
|
||||
{translateMessage(
|
||||
"Configure a dedicated WebDAV collection for Journal synchronisation. Opaque Journal needs ordinary WebDAV access. Adaptive Journal additionally runs an endpoint safety check before the profile is accepted."
|
||||
)}
|
||||
</Guidance>
|
||||
|
||||
<InputRow label={translateMessage("Endpoint URL")}>
|
||||
<input
|
||||
type="text"
|
||||
name="webdav-endpoint"
|
||||
placeholder="https://dav.example/remote.php/dav/files/alice"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
pattern="^https?://.+"
|
||||
bind:value={syncSetting.endpoint}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote warning visible={isEndpointInsecure}>
|
||||
{translateMessage("We can use only Secure (HTTPS) connections on Obsidian Mobile.")}
|
||||
</InfoNote>
|
||||
<InfoNote error visible={isEndpointInvalid}>
|
||||
{translateMessage("Enter a complete HTTP or HTTPS endpoint without a query string or fragment.")}
|
||||
</InfoNote>
|
||||
|
||||
<InputRow label={translateMessage("Username")}>
|
||||
<input
|
||||
type="text"
|
||||
name="webdav-username"
|
||||
placeholder={translateMessage("Enter your username")}
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={syncSetting.username}
|
||||
/>
|
||||
</InputRow>
|
||||
<InputRow label={translateMessage("Password")}>
|
||||
<Password
|
||||
name="webdav-password"
|
||||
placeholder={translateMessage("Enter your password")}
|
||||
bind:value={syncSetting.password}
|
||||
/>
|
||||
</InputRow>
|
||||
<InputRow label={translateMessage("Collection prefix")}>
|
||||
<input
|
||||
type="text"
|
||||
name="webdav-prefix"
|
||||
placeholder="livesync-journal/"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={syncSetting.prefix}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"Use a dedicated prefix. WebDAV listing scans the collection, so unrelated files and a long Journal history increase discovery work."
|
||||
)}
|
||||
</InfoNote>
|
||||
|
||||
<InputRow label={translateMessage("Use internal API")}>
|
||||
<input type="checkbox" name="webdav-use-internal-api" bind:checked={syncSetting.useCustomRequestHandler} />
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"Enable this when browser-compatible requests are blocked by CORS. It uses Obsidian's internal request API and may behave differently from standard browser fetch."
|
||||
)}
|
||||
</InfoNote>
|
||||
|
||||
<ExtraItems title={translateMessage("Advanced Settings")}>
|
||||
<InputRow label={translateMessage("Journal data format")}>
|
||||
<select name="webdav-journal-format" bind:value={syncSetting.journalFormat}>
|
||||
<option value="opaque-v1">{translateMessage("Opaque Journal (current format)")}</option>
|
||||
<option value="adaptive-v1">{translateMessage("Adaptive Journal (experimental)")}</option>
|
||||
</select>
|
||||
</InputRow>
|
||||
<InfoNote warning visible={isAdaptive}>
|
||||
{translateMessage(
|
||||
"Adaptive Journal uses immutable objects and a separate remote format. Existing Opaque Journal data is not migrated or read. Rebuild the remote when changing formats."
|
||||
)}
|
||||
</InfoNote>
|
||||
{#if isAdaptive}
|
||||
<InputRow label={translateMessage("Expected repository ID")}>
|
||||
<input
|
||||
type="text"
|
||||
name="webdav-expected-repository-id"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={syncSetting.expectedRepositoryId}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"This optional identity pins a trusted Adaptive repository. A Setup URI can supply it; leave it blank only when creating a repository or intentionally trusting the first compatible repository reached."
|
||||
)}
|
||||
</InfoNote>
|
||||
<InputRow label={translateMessage("Pack retrieval")}>
|
||||
<select name="webdav-pack-read-policy" bind:value={syncSetting.packReadPolicy}>
|
||||
<option value="whole-pack">{translateMessage("Download complete Packs")}</option>
|
||||
<option value="range">{translateMessage("Use HTTP Range requests")}</option>
|
||||
</select>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"Complete Pack reads favour throughput and are the portable default. Range reads can reduce transferred bytes, but this endpoint must pass the exact byte-range check."
|
||||
)}
|
||||
</InfoNote>
|
||||
<InfoNote caution>
|
||||
{translateMessage(
|
||||
"The Adaptive safety check writes, reads, lists, and removes disposable objects under a random probe prefix. It does not inspect Vault data."
|
||||
)}
|
||||
</InfoNote>
|
||||
{/if}
|
||||
<InputRow label={translateMessage("Custom Headers")}>
|
||||
<textarea
|
||||
name="webdav-custom-headers"
|
||||
placeholder="e.g., x-example-header: value"
|
||||
bind:value={syncSetting.customHeaders}
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
rows="4"
|
||||
></textarea>
|
||||
</InputRow>
|
||||
</ExtraItems>
|
||||
|
||||
{#if adaptiveSummary}
|
||||
{#if adaptiveSummary.required.kind === "verified"}
|
||||
<InfoNote notice>
|
||||
{translateMessage("Required Adaptive operations are supported by this WebDAV endpoint.")}
|
||||
</InfoNote>
|
||||
{:else if adaptiveSummary.required.kind === "unsupported"}
|
||||
<InfoNote error>
|
||||
{translateMessage("The WebDAV endpoint is missing required Adaptive operations: ${CAPABILITIES}.", {
|
||||
CAPABILITIES: adaptiveSummary.required.missing.join(", "),
|
||||
})}
|
||||
</InfoNote>
|
||||
{:else if adaptiveSummary.required.kind === "failed"}
|
||||
<InfoNote error>
|
||||
{translateMessage("The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).", {
|
||||
CATEGORY: adaptiveSummary.required.category,
|
||||
RETRY: adaptiveSummary.required.retry,
|
||||
})}
|
||||
</InfoNote>
|
||||
{:else}
|
||||
<InfoNote warning>{translateMessage("Required Adaptive operations were not checked.")}</InfoNote>
|
||||
{/if}
|
||||
|
||||
{#if adaptiveSummary.byteRange.kind === "verified"}
|
||||
<InfoNote notice>{translateMessage("Exact HTTP byte-range retrieval is supported.")}</InfoNote>
|
||||
{:else if adaptiveSummary.byteRange.kind === "unsupported"}
|
||||
<InfoNote warning>
|
||||
{translateMessage("HTTP byte-range retrieval is not supported. Complete Pack retrieval remains available.")}
|
||||
</InfoNote>
|
||||
{:else if adaptiveSummary.byteRange.kind === "failed"}
|
||||
<InfoNote warning>
|
||||
{translateMessage("The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).", {
|
||||
CATEGORY: adaptiveSummary.byteRange.category,
|
||||
RETRY: adaptiveSummary.byteRange.retry,
|
||||
})}
|
||||
</InfoNote>
|
||||
{:else}
|
||||
<InfoNote warning>
|
||||
{translateMessage(
|
||||
"HTTP byte-range retrieval was not checked because the required safety check did not complete."
|
||||
)}
|
||||
</InfoNote>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if inspectionIsCurrent && inspection?.available && !isAdaptive}
|
||||
<InfoNote notice>
|
||||
{translateMessage("WebDAV access and the selected Journal format were verified.")}
|
||||
</InfoNote>
|
||||
{/if}
|
||||
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"The saved connection contains credentials and custom headers. Configuration encryption protects exported Setup data when it is enabled; do not share a plain connection string."
|
||||
)}
|
||||
</InfoNote>
|
||||
<InfoNote error visible={error !== ""}>{error}</InfoNote>
|
||||
|
||||
{#if processing}
|
||||
{translateMessage("Checking connection... Please wait.")}
|
||||
{:else}
|
||||
<UserDecisions>
|
||||
{#if inspectionIsCurrent && inspection?.available && inspectedSettings}
|
||||
<Decision
|
||||
title={setupMode === "settings"
|
||||
? translateMessage("Save verified settings")
|
||||
: translateMessage("Continue with verified settings")}
|
||||
important
|
||||
commit={() => commitVerified()}
|
||||
/>
|
||||
<Decision
|
||||
title={isAdaptive
|
||||
? translateMessage("Run endpoint safety check")
|
||||
: translateMessage("Test WebDAV connection")}
|
||||
disabled={!isEndpointValid}
|
||||
commit={() => checkConnection()}
|
||||
/>
|
||||
{:else}
|
||||
<Decision
|
||||
title={isAdaptive
|
||||
? translateMessage("Run endpoint safety check")
|
||||
: translateMessage("Test WebDAV connection")}
|
||||
important
|
||||
disabled={!isEndpointValid}
|
||||
commit={() => checkConnection()}
|
||||
/>
|
||||
{/if}
|
||||
{#if setupMode === "settings"}
|
||||
<InfoNote warning>
|
||||
{translateMessage(
|
||||
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected."
|
||||
)}
|
||||
</InfoNote>
|
||||
<Decision
|
||||
title={translateMessage("Save without connecting")}
|
||||
disabled={!isEndpointValid}
|
||||
commit={() => commit()}
|
||||
/>
|
||||
{/if}
|
||||
<Decision title={translateMessage("Cancel")} commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
{/if}
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
EncryptionSettings,
|
||||
ObsidianLiveSyncSettings,
|
||||
P2PConnectionInfo,
|
||||
WebDAVSyncSetting,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
|
||||
import type { BuiltInRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import type { RemoteSetupChoice } from "@/modules/features/SetupWizard/RemoteSetupRegistry";
|
||||
@@ -104,6 +105,13 @@ export type SetupRemoteE2EEResultType = typeof TYPE_CANCELLED | EncryptionSettin
|
||||
|
||||
export type SetupRemoteBucketResultType = typeof TYPE_CANCELLED | BucketSyncSetting;
|
||||
|
||||
export type SetupRemoteWebDAVResultType = typeof TYPE_CANCELLED | WebDAVSyncSetting;
|
||||
export type WebDAVSetupMode = "onboarding" | "settings";
|
||||
export type SetupRemoteWebDAVInitialData = {
|
||||
settings: WebDAVSyncSetting;
|
||||
mode: WebDAVSetupMode;
|
||||
};
|
||||
|
||||
export type SetupRemoteCouchDBResultType = typeof TYPE_CANCELLED | CouchDBConnection;
|
||||
export type CouchDBSetupMode = "create-or-connect" | "connect-existing" | "settings";
|
||||
export type SetupRemoteCouchDBInitialData = {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
REMOTE_WEBDAV,
|
||||
journalProtocolConfigurationForSettings,
|
||||
parseWebDAVConnectionURI,
|
||||
serialiseWebDAVConnectionURI,
|
||||
type AdaptiveJournalPackReadPolicyV1,
|
||||
type JournalFormatV1,
|
||||
type JournalStorageAdaptiveCapabilityInspection,
|
||||
type JournalStorageCapabilityInspection,
|
||||
type WebDAVConnection,
|
||||
type WebDAVSyncSetting,
|
||||
} from "@vrtmrz/livesync-commonlib/journal-storage";
|
||||
|
||||
export type WebDAVJournalForm = WebDAVConnection & {
|
||||
expectedRepositoryId: string;
|
||||
journalFormat: JournalFormatV1;
|
||||
packReadPolicy: AdaptiveJournalPackReadPolicyV1;
|
||||
};
|
||||
|
||||
export type WebDAVCapabilitySummary =
|
||||
| { kind: "verified" }
|
||||
| { kind: "not-checked" }
|
||||
| { kind: "unsupported"; missing: string[] }
|
||||
| {
|
||||
kind: "failed";
|
||||
category: "authentication" | "invalid-response" | "permission" | "rate-limited" | "unavailable" | "unknown";
|
||||
retry: "later" | "never" | "verify-first";
|
||||
};
|
||||
|
||||
export type WebDAVAdaptiveCapabilitySummary = {
|
||||
byteRange: WebDAVCapabilitySummary;
|
||||
required: WebDAVCapabilitySummary;
|
||||
};
|
||||
|
||||
const emptyWebDAVConnection: WebDAVConnection = {
|
||||
customHeaders: "",
|
||||
endpoint: "",
|
||||
password: "",
|
||||
prefix: "",
|
||||
useCustomRequestHandler: false,
|
||||
username: "",
|
||||
};
|
||||
|
||||
function resolveProtocol(settings: WebDAVSyncSetting) {
|
||||
return journalProtocolConfigurationForSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_WEBDAV,
|
||||
...settings,
|
||||
});
|
||||
}
|
||||
|
||||
export function webDAVJournalFormFromSettings(settings: WebDAVSyncSetting): WebDAVJournalForm {
|
||||
const connection = settings.webDAVactiveConnectionURI.trim()
|
||||
? parseWebDAVConnectionURI(settings.webDAVactiveConnectionURI.trim())
|
||||
: emptyWebDAVConnection;
|
||||
const protocol = resolveProtocol(settings);
|
||||
return {
|
||||
...connection,
|
||||
...protocol,
|
||||
};
|
||||
}
|
||||
|
||||
export function webDAVSyncSettingsFromForm(form: WebDAVJournalForm): WebDAVSyncSetting {
|
||||
const journalFormat = form.journalFormat;
|
||||
const settings: WebDAVSyncSetting = {
|
||||
webDAVactiveConnectionURI: serialiseWebDAVConnectionURI({
|
||||
customHeaders: form.customHeaders.trim(),
|
||||
endpoint: form.endpoint.trim(),
|
||||
password: form.password,
|
||||
prefix: form.prefix.trim(),
|
||||
useCustomRequestHandler: form.useCustomRequestHandler,
|
||||
username: form.username.trim(),
|
||||
}),
|
||||
expectedRepositoryId: journalFormat === "adaptive-v1" ? form.expectedRepositoryId.trim() : "",
|
||||
journalFormat,
|
||||
packReadPolicy: journalFormat === "adaptive-v1" ? form.packReadPolicy : "whole-pack",
|
||||
};
|
||||
resolveProtocol(settings);
|
||||
return settings;
|
||||
}
|
||||
|
||||
function summariseCapabilityInspection(inspection: JournalStorageCapabilityInspection): WebDAVCapabilitySummary {
|
||||
switch (inspection.status) {
|
||||
case "verified":
|
||||
return { kind: "verified" };
|
||||
case "not-checked":
|
||||
return { kind: "not-checked" };
|
||||
case "unsupported":
|
||||
return { kind: "unsupported", missing: [...inspection.missing] };
|
||||
case "failed":
|
||||
return {
|
||||
category: inspection.failure.category,
|
||||
kind: "failed",
|
||||
retry: inspection.failure.retry,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function summariseAdaptiveCapabilityInspection(
|
||||
inspection: JournalStorageAdaptiveCapabilityInspection
|
||||
): WebDAVAdaptiveCapabilitySummary {
|
||||
return {
|
||||
byteRange: summariseCapabilityInspection(inspection.byteRange),
|
||||
required: summariseCapabilityInspection(inspection.required),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { serialiseWebDAVConnectionURI } from "@vrtmrz/livesync-commonlib/journal-storage";
|
||||
import {
|
||||
summariseAdaptiveCapabilityInspection,
|
||||
webDAVJournalFormFromSettings,
|
||||
webDAVSyncSettingsFromForm,
|
||||
} from "./webDAVJournalSettings.ts";
|
||||
|
||||
const repositoryId = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
describe("WebDAV Journal settings", () => {
|
||||
it("round-trips connection fields separately from Adaptive protocol fields", () => {
|
||||
const settings = {
|
||||
webDAVactiveConnectionURI: serialiseWebDAVConnectionURI({
|
||||
customHeaders: "X-Vault: notes",
|
||||
endpoint: "https://dav.example/remote.php/dav/files/alice",
|
||||
password: "p@ss word",
|
||||
prefix: "vault/notes/",
|
||||
useCustomRequestHandler: true,
|
||||
username: "alice@example.com",
|
||||
}),
|
||||
expectedRepositoryId: repositoryId,
|
||||
journalFormat: "adaptive-v1" as const,
|
||||
packReadPolicy: "range" as const,
|
||||
};
|
||||
|
||||
const form = webDAVJournalFormFromSettings(settings);
|
||||
|
||||
expect(form).toEqual({
|
||||
customHeaders: "X-Vault: notes",
|
||||
endpoint: "https://dav.example/remote.php/dav/files/alice",
|
||||
expectedRepositoryId: repositoryId,
|
||||
journalFormat: "adaptive-v1",
|
||||
packReadPolicy: "range",
|
||||
password: "p@ss word",
|
||||
prefix: "vault/notes/",
|
||||
useCustomRequestHandler: true,
|
||||
username: "alice@example.com",
|
||||
});
|
||||
expect(webDAVSyncSettingsFromForm(form)).toEqual(settings);
|
||||
});
|
||||
|
||||
it("normalises editable text and removes Adaptive-only options from Opaque settings", () => {
|
||||
const settings = webDAVSyncSettingsFromForm({
|
||||
customHeaders: " X-Vault: notes ",
|
||||
endpoint: " http://localhost:8080/dav ",
|
||||
expectedRepositoryId: ` ${repositoryId} `,
|
||||
journalFormat: "opaque-v1",
|
||||
packReadPolicy: "range",
|
||||
password: " password with spaces ",
|
||||
prefix: " vault/notes/ ",
|
||||
useCustomRequestHandler: false,
|
||||
username: " alice ",
|
||||
});
|
||||
|
||||
expect(settings).toEqual({
|
||||
webDAVactiveConnectionURI:
|
||||
"sls+webdav://alice:%20password%20with%20spaces%20@localhost:8080/dav?insecure=true&prefix=vault%2Fnotes%2F&headers=X-Vault%3A+notes",
|
||||
expectedRepositoryId: "",
|
||||
journalFormat: "opaque-v1",
|
||||
packReadPolicy: "whole-pack",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an invalid pinned repository identity", () => {
|
||||
expect(() =>
|
||||
webDAVSyncSettingsFromForm({
|
||||
customHeaders: "",
|
||||
endpoint: "https://dav.example/vault",
|
||||
expectedRepositoryId: "AA",
|
||||
journalFormat: "adaptive-v1",
|
||||
packReadPolicy: "whole-pack",
|
||||
password: "secret",
|
||||
prefix: "",
|
||||
useCustomRequestHandler: false,
|
||||
username: "alice",
|
||||
})
|
||||
).toThrow("expectedRepositoryId must be a canonical base64url-encoded 32-byte value");
|
||||
});
|
||||
|
||||
it("presents required capabilities and optional byte-range support independently", () => {
|
||||
expect(
|
||||
summariseAdaptiveCapabilityInspection({
|
||||
required: { status: "verified" },
|
||||
byteRange: { missing: ["byte-range"], status: "unsupported" },
|
||||
})
|
||||
).toEqual({
|
||||
required: { kind: "verified" },
|
||||
byteRange: { kind: "unsupported", missing: ["byte-range"] },
|
||||
});
|
||||
|
||||
expect(
|
||||
summariseAdaptiveCapabilityInspection({
|
||||
required: {
|
||||
failure: { category: "authentication", retry: "never" },
|
||||
status: "failed",
|
||||
},
|
||||
byteRange: { status: "not-checked" },
|
||||
})
|
||||
).toEqual({
|
||||
required: { category: "authentication", kind: "failed", retry: "never" },
|
||||
byteRange: { kind: "not-checked" },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user