mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-06 21:05:21 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 339a4bc9c1 |
@@ -1,360 +0,0 @@
|
||||
# Architectural Decision Record: Setting Definition Repository
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Release
|
||||
|
||||
Not scheduled. Intended as a design direction before refactoring the settings dialogue.
|
||||
|
||||
## Context
|
||||
|
||||
The current settings dialogue is implemented around `ObsidianLiveSyncSettingTab`.
|
||||
It is effective and feature rich, but several responsibilities are combined in the
|
||||
same layer:
|
||||
|
||||
- editing-state buffering and dirty-state tracking,
|
||||
- local-only dialogue state such as `configPassphrase`, `preset`, `syncMode`, and
|
||||
`deviceAndVaultName`,
|
||||
- persistence through `SettingService`,
|
||||
- Obsidian `Setting` component rendering,
|
||||
- pane layout and visibility rules,
|
||||
- validation and value coercion,
|
||||
- setup wizard transitions,
|
||||
- rebuild/restart side effects,
|
||||
- remote diagnostics and maintenance actions.
|
||||
|
||||
Some setting metadata already exists in `configurationNames` and related setting
|
||||
constants. This is a useful seed, but it is not a complete source of truth. The
|
||||
metadata does not currently describe storage domain, value kind, validation,
|
||||
capability requirements, migration behaviour, cross-setting dependencies, or
|
||||
whether a value should be rendered by a generic control or a custom pane.
|
||||
|
||||
The settings dialogue also contains a mix of simple controls and workflow panels.
|
||||
Examples:
|
||||
|
||||
- Simple controls: `showStatusOnEditor`, `syncOnSave`, `customChunkSize`,
|
||||
`readChunksOnline`, `useTimeouts`.
|
||||
- Derived or dialogue-only values: `syncMode`, `preset`, `configPassphrase`,
|
||||
`deviceAndVaultName`.
|
||||
- Workflow panels: remote configuration management, E2EE setup, setup wizard,
|
||||
local/remote rebuild, maintenance commands, Customisation Sync dialogue open.
|
||||
|
||||
This matters primarily for maintainability and platform independence. A setting
|
||||
should have one shared definition of its domain meaning regardless of whether it
|
||||
is displayed in Obsidian, surfaced in a CLI, used by a WebApp, or documented.
|
||||
Tests benefit from this separation, but testability is a consequence rather than
|
||||
the main design goal.
|
||||
|
||||
Real Obsidian E2E remains the right signal for Obsidian shell behaviour such as
|
||||
opening the settings tab, rendering real Obsidian components, and verifying
|
||||
user-visible workflows. Harness-based tests can then focus on deterministic
|
||||
setting semantics that no longer depend on mounting the whole Obsidian setting
|
||||
tab.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce a platform-neutral Setting Definition Repository as the source of
|
||||
truth for setting metadata and setting semantics.
|
||||
|
||||
The repository should live in the shared domain layer, not under the Obsidian
|
||||
dialogue implementation and not under a generic model bucket. In the current
|
||||
layout this means `src/lib/src/common/settings`.
|
||||
|
||||
The repository should not be an Obsidian UI abstraction. It should describe what
|
||||
a setting is and how it behaves. Obsidian, CLI, WebApp, tests, and documentation
|
||||
can then consume the same definitions through their own renderers or adapters.
|
||||
The Obsidian settings tab should use an Obsidian renderer that maps repository
|
||||
definitions to native Obsidian `Setting` components for simple controls, while
|
||||
workflow panes remain custom.
|
||||
|
||||
The existing Obsidian setting dialogue should be migrated incrementally. Complex
|
||||
workflow panes should remain custom-rendered at first. Simple controls should
|
||||
move to repository-driven rendering first.
|
||||
|
||||
## Proposed Model
|
||||
|
||||
Each setting definition should describe a single setting key or a dialogue-only
|
||||
virtual key.
|
||||
|
||||
```ts
|
||||
type SettingStorageDomain = "persisted" | "local" | "derived" | "ephemeral";
|
||||
|
||||
type SettingValueKind = "boolean" | "text" | "password" | "number" | "select" | "textarea" | "string-list" | "custom";
|
||||
|
||||
type SettingCapability = "database-user" | "server-admin" | "filesystem" | "obsidian-shell" | "obsidian-plugin-host";
|
||||
|
||||
type SettingDefinition<TSettings, TKey extends keyof TSettings | string> = {
|
||||
key: TKey;
|
||||
storage: SettingStorageDomain;
|
||||
kind: SettingValueKind;
|
||||
defaultValue?: unknown;
|
||||
labelKey: string;
|
||||
descriptionKey?: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
pane?: string;
|
||||
section?: string;
|
||||
level?: "ADVANCED" | "POWER_USER" | "EDGE_CASE";
|
||||
status?: "BETA" | "ALPHA" | "EXPERIMENTAL";
|
||||
obsolete?: boolean;
|
||||
internal?: boolean;
|
||||
placeholder?: string;
|
||||
options?: Record<string, string>;
|
||||
secret?: boolean;
|
||||
requiredCapabilities?: SettingCapability[];
|
||||
visible?: (context: SettingEvaluationContext<TSettings>) => boolean;
|
||||
enabled?: (context: SettingEvaluationContext<TSettings>) => boolean;
|
||||
validate?: (value: unknown, context: SettingEvaluationContext<TSettings>) => SettingValidationResult;
|
||||
coerce?: (value: unknown, context: SettingEvaluationContext<TSettings>) => unknown;
|
||||
affects?: SettingEffect[];
|
||||
commit?: SettingCommitPolicy<TKey>;
|
||||
render?: "auto" | "custom";
|
||||
};
|
||||
```
|
||||
|
||||
`SettingEvaluationContext` should carry the current editing settings, persisted
|
||||
settings, platform capabilities, and remote capability information if known. It
|
||||
should not carry an Obsidian `App`.
|
||||
|
||||
`labelKey` and `descriptionKey` should be i18n keys. During migration, the key
|
||||
may be the literal English text already used by `configurationNames` and
|
||||
`SettingInformation`. This matches the current i18n behaviour where an unknown
|
||||
key resolves to the key itself, avoids adding translation resources up front, and
|
||||
lets us later replace literal keys with stable resource keys without changing
|
||||
consumers. `label` and `description` remain compatibility aliases while existing
|
||||
code still expects resolved strings.
|
||||
|
||||
`internal` should mark settings that are not currently editable from the UI.
|
||||
Obsolete settings are internal by default. Missing UI metadata alone should not
|
||||
make a setting internal; `kind`, `render`, and explicit `internal` metadata
|
||||
should decide whether the automatic renderer can safely handle it.
|
||||
|
||||
`commit` should describe when a value is persisted, not how a button is rendered.
|
||||
Immediate settings can save on change. Explicit settings are held in the editing
|
||||
buffer until an apply action commits the configured group. This keeps apply
|
||||
buttons out of the repository while still making grouped save behaviour
|
||||
testable.
|
||||
|
||||
## Storage Domains
|
||||
|
||||
Settings should be classified by where they live:
|
||||
|
||||
- `persisted`: normal `ObsidianLiveSyncSettings` values saved through
|
||||
`SettingService`.
|
||||
- `local`: values stored outside the main settings document, for example local
|
||||
storage or device/vault identity.
|
||||
- `derived`: values computed from persisted settings, for example `syncMode`.
|
||||
- `ephemeral`: dialogue-only inputs such as `preset`.
|
||||
|
||||
This makes current special cases explicit:
|
||||
|
||||
- `configPassphrase` is local-only.
|
||||
- `deviceAndVaultName` is local/service managed.
|
||||
- `syncMode` is derived from `liveSync` and `periodicReplication`.
|
||||
- `preset` is ephemeral and expands to several persisted settings.
|
||||
|
||||
## Rendering Strategy
|
||||
|
||||
The repository should support generic rendering, but it should not force every
|
||||
pane to become schema-driven immediately.
|
||||
|
||||
Use three levels:
|
||||
|
||||
1. **Auto-rendered controls**
|
||||
Simple `boolean`, `number`, `text`, `password`, `select`, and `textarea`
|
||||
settings. These can replace many `new Setting(...).autoWire...` calls.
|
||||
|
||||
2. **Repository-defined groups with custom sections**
|
||||
A pane can declare layout, headings, and order through the repository but keep
|
||||
a custom renderer for the section body.
|
||||
|
||||
3. **Fully custom workflow panes**
|
||||
Remote configuration management, E2EE setup, setup wizard, maintenance, and
|
||||
rebuild flows should remain custom until their side effects are separately
|
||||
modelled.
|
||||
|
||||
The Obsidian setting dialogue becomes a renderer of repository definitions plus a
|
||||
host for custom workflow panes.
|
||||
|
||||
## Side Effects
|
||||
|
||||
Setting changes should distinguish value persistence from effects.
|
||||
|
||||
Examples of effects:
|
||||
|
||||
- `requires-local-rebuild`
|
||||
- `requires-remote-rebuild`
|
||||
- `requires-restart`
|
||||
- `requires-apply-settings`
|
||||
- `suspends-sync`
|
||||
- `updates-unresolved-error-ui`
|
||||
- `changes-active-remote`
|
||||
- `expands-preset`
|
||||
|
||||
The current `isNeedRebuildLocal()` and `isNeedRebuildRemote()` methods should
|
||||
eventually be replaced by repository metadata. This would make rebuild prompts
|
||||
testable without rendering the full settings tab.
|
||||
|
||||
## Capability Requirements
|
||||
|
||||
Some settings and actions require capabilities that not all users or platforms
|
||||
have.
|
||||
|
||||
Examples:
|
||||
|
||||
- CouchDB server diagnostics and automatic CouchDB repair require server-admin
|
||||
capability.
|
||||
- Normal CouchDB sync requires only database-user capability.
|
||||
- Hidden File Sync and Customisation Sync require filesystem capability.
|
||||
- Obsidian plug-in reload requires obsidian-plugin-host capability.
|
||||
- Opening settings panes and workspace views requires obsidian-shell capability.
|
||||
|
||||
Capability metadata should be used for:
|
||||
|
||||
- warning text in Obsidian settings,
|
||||
- disabling unsupported actions,
|
||||
- CLI/WebApp help output,
|
||||
- Harness tests for visibility and enabled-state rules.
|
||||
|
||||
The repository should not introduce a generic cross-platform `PluginManager`
|
||||
concept. Obsidian plug-in host behaviour should remain an Obsidian-specific
|
||||
adapter or custom workflow.
|
||||
|
||||
## Current Assumptions to Preserve
|
||||
|
||||
- Settings can be edited in a buffer before being saved.
|
||||
- Some values save immediately unless `holdValue` is set.
|
||||
- Some values require explicit Apply buttons.
|
||||
- Visibility and enabled-state often depend on other editing values.
|
||||
- Some settings are hidden in setup wizard mode.
|
||||
- Advanced, power-user, and edge-case levels remain supported.
|
||||
- The dialogue can be reloaded while preserving dirty local edits.
|
||||
- Existing `SettingService` remains responsible for encryption, persistence,
|
||||
migration, and applying settings.
|
||||
- Existing complex setup and remote configuration workflows remain custom.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Repository Skeleton
|
||||
|
||||
- Create a repository module in the shared setting domain
|
||||
(`src/lib/src/common/settings`), not under the Obsidian dialogue folder.
|
||||
- Move existing `configurationNames` metadata into repository definitions without
|
||||
changing runtime behaviour.
|
||||
- Add storage domain, kind, i18n keys, internal marker, pane, section, level, and
|
||||
secret metadata for a small subset of settings.
|
||||
- Keep `getConfig()`, `getConfName()`, and existing callers working through a
|
||||
compatibility facade.
|
||||
|
||||
### Phase 2: Evaluation API
|
||||
|
||||
- Add pure functions:
|
||||
- `getSettingDefinition(key)`
|
||||
- `listSettingDefinitions(filter)`
|
||||
- `evaluateSetting(definition, context)`
|
||||
- `validateSettingValue(key, value, context)`
|
||||
- `getSettingEffects(changedKeys, context)`
|
||||
- Add unit tests for derived values, visibility, enabled-state, validation, and
|
||||
rebuild/restart effects.
|
||||
|
||||
### Phase 3: Obsidian Renderer for Simple Controls
|
||||
|
||||
- Add a small renderer that maps repository definitions to Obsidian `Setting`
|
||||
controls.
|
||||
- Migrate one low-risk pane first, likely Appearance/Logging or Advanced memory
|
||||
cache settings.
|
||||
- Keep custom panes untouched.
|
||||
- Keep `LiveSyncSetting` as a compatibility wrapper during migration.
|
||||
|
||||
### Phase 4: Derived and Local Values
|
||||
|
||||
- Model `syncMode`, `preset`, `configPassphrase`, and `deviceAndVaultName`
|
||||
explicitly.
|
||||
- Replace ad hoc save paths in `ObsidianLiveSyncSettingTab` with storage-domain
|
||||
handlers.
|
||||
- Keep user-visible behaviour unchanged.
|
||||
|
||||
### Phase 5: Effects and Capability Warnings
|
||||
|
||||
- Replace `isNeedRebuildLocal()` and `isNeedRebuildRemote()` with
|
||||
repository-driven effect calculation.
|
||||
- Model explicit commit groups for settings that must be applied together, for
|
||||
example configuration encryption passphrase settings, setting sync file, and
|
||||
database suffix changes.
|
||||
- Add capability metadata for CouchDB diagnostics, repair, Hidden File Sync, and
|
||||
Obsidian-only plug-in operations.
|
||||
- Use this to improve warnings for database-scoped CouchDB users and
|
||||
administrator-only actions.
|
||||
|
||||
### Phase 6: Documentation and Non-Obsidian Consumers
|
||||
|
||||
- Treat documentation as an authored source, not as an output that must be fully
|
||||
generated from code.
|
||||
- Optionally combine repository metadata with a documentation source such as YAML
|
||||
to generate or lint `docs/settings.md`.
|
||||
- Use the repository to verify that documented settings exist, that defaults and
|
||||
storage domains are consistent, and that internal settings are intentionally
|
||||
omitted or documented as internal.
|
||||
- Expose repository metadata to CLI/WebApp where useful.
|
||||
- Let Harness tests assert the same repository semantics used by Obsidian.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Use Harness or unit tests for:
|
||||
|
||||
- default value coverage,
|
||||
- type/kind consistency,
|
||||
- every persisted setting has a definition or is explicitly internal,
|
||||
- visibility and enabled-state predicates,
|
||||
- derived values such as `syncMode`,
|
||||
- preset expansion,
|
||||
- rebuild/restart effect calculation,
|
||||
- capability warnings.
|
||||
|
||||
Use real Obsidian E2E for:
|
||||
|
||||
- opening the actual setting tab,
|
||||
- rendering Obsidian `Setting` components,
|
||||
- setup wizard flow,
|
||||
- remote configuration workflow,
|
||||
- actual restart prompts,
|
||||
- workflows that depend on Obsidian settings shell behaviour.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Setting semantics are maintained in one platform-neutral place.
|
||||
- Setting semantics become testable without mounting Obsidian UI.
|
||||
- Documentation, CLI, WebApp, and Obsidian can share setting metadata where it is
|
||||
useful.
|
||||
- Capability-sensitive settings become explicit.
|
||||
- Future settings are less likely to be implemented in only one surface.
|
||||
- The Obsidian settings dialogue can be refactored incrementally.
|
||||
|
||||
Negative:
|
||||
|
||||
- There will be a temporary compatibility layer between old setting constants and
|
||||
the repository.
|
||||
- Some panes will remain custom, so the repository will not remove all UI code.
|
||||
- Definition metadata can become stale if not enforced by tests.
|
||||
- Over-generalising workflow panes would make the repository harder to maintain.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not replace `SettingService` persistence in the first phase.
|
||||
- Do not make Obsidian plug-in host operations cross-platform.
|
||||
- Do not convert all setting panes to schema-driven UI at once.
|
||||
- Do not require real Obsidian E2E for every setting definition.
|
||||
- Do not remove custom renderers for remote setup, E2EE setup, or maintenance
|
||||
workflows.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- What should the exact `CapabilityProvider` interface look like for static
|
||||
platform capabilities and runtime-probed remote capabilities? This should be
|
||||
decided while implementing the Obsidian renderer so the interface follows a
|
||||
real consumer instead of an abstract capability model.
|
||||
+7
-1
@@ -153,7 +153,7 @@ Show verbose log. Please enable when you report the logs
|
||||
Self-hosted LiveSync supports multiple remote connection profiles under **Remote Server** -> **Remote Databases**. This allows you to save and switch between multiple databases or bucket configurations in a single vault.
|
||||
|
||||
- **➕ Add new connection**: Create a new connection profile by launching the setup dialogue.
|
||||
- **📥 Import connection**: Paste a connection string (e.g., `sls+https://...`, `sls+s3://...`, `sls+p2p://...`) to import a remote configuration profile.
|
||||
- **📥 Import connection**: Paste a connection string (e.g., `sls+https://...`, `sls+s3://...`, `sls+webdav://...`, `sls+p2p://...`) to import a remote configuration profile.
|
||||
- **🔧 Configure**: Open the setup dialogue to edit settings for the selected connection profile.
|
||||
- **✅ Activate**: Select and activate this profile as the current active remote.
|
||||
- **🗑️ Delete**: Remove this connection profile from the list.
|
||||
@@ -164,6 +164,12 @@ Setting key: remoteType
|
||||
|
||||
The active remote server type. This is automatically projected to the legacy configuration when you activate a connection profile.
|
||||
|
||||
#### WebDAV Connection URI (Experimental)
|
||||
|
||||
Setting key: webDAVactiveConnectionURI
|
||||
|
||||
The active WebDAV connection URI used by experimental Journal synchronisation storage. This is configured in the **WebDAV Configuration** setup dialogue as a single `sls+webdav://...` URI. Optional query parameters include `prefix`, `useProxy=true`, and `insecure=true` for local HTTP testing. This feature is still a proof of concept for pluggable Journal storage backends and should be treated as experimental.
|
||||
|
||||
### 2. Notification
|
||||
|
||||
#### Notify when the estimated remote storage size exceeds on start up
|
||||
|
||||
@@ -64,6 +64,11 @@
|
||||
"test:docker-s3:start": "npm run test:docker-s3:up && sleep 3 && npm run test:docker-s3:init",
|
||||
"test:docker-s3:down": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/minio-stop.sh",
|
||||
"test:docker-s3:stop": "npm run test:docker-s3:down",
|
||||
"test:docker-webdav:up": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/webdav-start.sh",
|
||||
"test:docker-webdav:init": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/webdav-init.sh",
|
||||
"test:docker-webdav:start": "npm run test:docker-webdav:up && sleep 3 && npm run test:docker-webdav:init",
|
||||
"test:docker-webdav:down": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/webdav-stop.sh",
|
||||
"test:docker-webdav:stop": "npm run test:docker-webdav:down",
|
||||
"test:docker-p2p:up": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/p2p-start.sh",
|
||||
"test:docker-p2p:init": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/p2p-init.sh",
|
||||
"test:docker-p2p:start": "npm run test:docker-p2p:up && sleep 3 && npm run test:docker-p2p:init",
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as os from "os";
|
||||
import * as processSetting from "@lib/API/processSetting";
|
||||
import { ConnectionStringParser } from "@lib/common/ConnectionString";
|
||||
import { configURIBase } from "@lib/common/models/shared.const";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P } from "@lib/common/types";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P, REMOTE_WEBDAV } from "@lib/common/types";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { runCommand } from "./runCommand";
|
||||
import type { CLIOptions } from "./types";
|
||||
@@ -205,6 +205,22 @@ const protocolFixtures: ProtocolFixture[] = [
|
||||
expect(settings.P2P_AppID).toBe("self-hosted-livesync");
|
||||
},
|
||||
},
|
||||
{
|
||||
protocol: "webdav",
|
||||
connectionString: ConnectionStringParser.serialize({
|
||||
type: "webdav",
|
||||
settings: {
|
||||
webDAVactiveConnectionURI:
|
||||
"sls+webdav://user:pass@webdav.example.com/dav?prefix=vault%2F&headers=x-test%3A1&useProxy=true",
|
||||
},
|
||||
}),
|
||||
assertProjectedFields: (settings) => {
|
||||
expect(settings.remoteType).toBe(REMOTE_WEBDAV);
|
||||
expect(settings.webDAVactiveConnectionURI).toBe(
|
||||
"sls+webdav://user:pass@webdav.example.com/dav?prefix=vault%2F&headers=x-test%3A1&useProxy=true"
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("runCommand abnormal cases", () => {
|
||||
@@ -564,6 +580,10 @@ describe("runCommand abnormal cases", () => {
|
||||
"p2p",
|
||||
"sls+p2p://room-abc?passphrase=pass-123&relays=wss%3A%2F%2Frelay.example&appId=self-hosted-livesync",
|
||||
] as const,
|
||||
[
|
||||
"webdav",
|
||||
"sls+webdav://user:pass@webdav.example.com/dav?prefix=vault%2F&headers=x-test%3A1&useProxy=true",
|
||||
] as const,
|
||||
])("remote command round-trip works for %s", async (_protocol, initialConnStr) => {
|
||||
const core = createCoreMock();
|
||||
|
||||
|
||||
+1
-1
Submodule src/lib updated: c7c5fe21be...2ee5d2055a
@@ -1,5 +1,5 @@
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { REMOTE_MINIO, REMOTE_P2P, type RemoteDBSettings } from "@lib/common/types";
|
||||
import { REMOTE_P2P, isJournalRemoteType, type RemoteDBSettings } from "@lib/common/types";
|
||||
import { LiveSyncCouchDBReplicator } from "@lib/replication/couchdb/LiveSyncReplicator";
|
||||
import type { LiveSyncAbstractReplicator } from "@lib/replication/LiveSyncAbstractReplicator";
|
||||
import { AbstractModule } from "@/modules/AbstractModule";
|
||||
@@ -9,7 +9,7 @@ export class ModuleReplicatorCouchDB extends AbstractModule {
|
||||
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
|
||||
const settings = { ...this.settings, ...settingOverride };
|
||||
// If new remote types were added, add them here. Do not use `REMOTE_COUCHDB` directly for the safety valve.
|
||||
if (settings.remoteType == REMOTE_MINIO || settings.remoteType == REMOTE_P2P) {
|
||||
if (isJournalRemoteType(settings.remoteType) || settings.remoteType == REMOTE_P2P) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return Promise.resolve(new LiveSyncCouchDBReplicator(this.core));
|
||||
@@ -17,7 +17,7 @@ export class ModuleReplicatorCouchDB extends AbstractModule {
|
||||
_everyAfterResumeProcess(): Promise<boolean> {
|
||||
if (this.services.appLifecycle.isSuspended()) return Promise.resolve(true);
|
||||
if (!this.services.appLifecycle.isReady()) return Promise.resolve(true);
|
||||
if (this.settings.remoteType != REMOTE_MINIO && this.settings.remoteType != REMOTE_P2P) {
|
||||
if (!isJournalRemoteType(this.settings.remoteType) && this.settings.remoteType != REMOTE_P2P) {
|
||||
const LiveSyncEnabled = this.settings.liveSync;
|
||||
const continuous = LiveSyncEnabled;
|
||||
const eventualOnStart = !LiveSyncEnabled && this.settings.syncOnStart;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { REMOTE_MINIO, type RemoteDBSettings } from "@lib/common/types";
|
||||
import { isJournalRemoteType, type RemoteDBSettings } from "@lib/common/types";
|
||||
import { LiveSyncJournalReplicator } from "@lib/replication/journal/LiveSyncJournalReplicator";
|
||||
import type { LiveSyncAbstractReplicator } from "@lib/replication/LiveSyncAbstractReplicator";
|
||||
import type { LiveSyncCore } from "@/main";
|
||||
@@ -7,7 +7,7 @@ import { AbstractModule } from "@/modules/AbstractModule";
|
||||
export class ModuleReplicatorMinIO extends AbstractModule {
|
||||
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
|
||||
const settings = { ...this.settings, ...settingOverride };
|
||||
if (settings.remoteType == REMOTE_MINIO) {
|
||||
if (isJournalRemoteType(settings.remoteType)) {
|
||||
return Promise.resolve(new LiveSyncJournalReplicator(this.core));
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
|
||||
@@ -8,13 +8,7 @@ import {
|
||||
type ValueComponent,
|
||||
} from "@/deps.ts";
|
||||
import { unique } from "octagonal-wheels/collection";
|
||||
import {
|
||||
LEVEL_ADVANCED,
|
||||
LEVEL_POWER_USER,
|
||||
statusDisplay,
|
||||
type ConfigurationItem,
|
||||
type SettingDefinition,
|
||||
} from "@lib/common/types.ts";
|
||||
import { LEVEL_ADVANCED, LEVEL_POWER_USER, statusDisplay, type ConfigurationItem } from "@lib/common/types.ts";
|
||||
import { type ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import {
|
||||
type AllSettingItemKey,
|
||||
@@ -24,21 +18,9 @@ import {
|
||||
type AllNumericItemKey,
|
||||
type AllBooleanItemKey,
|
||||
} from "./settingConstants.ts";
|
||||
import { $msg, $t } from "@lib/common/i18n.ts";
|
||||
import { $msg } from "@lib/common/i18n.ts";
|
||||
import { wrapMemo, type AutoWireOption, type OnUpdateResult } from "./SettingPane.ts";
|
||||
|
||||
function configurationFromDefinition(definition: SettingDefinition<AllSettingItemKey>): ConfigurationItem {
|
||||
return {
|
||||
name: $t(definition.labelKey),
|
||||
desc: definition.descriptionKey ? $t(definition.descriptionKey) : undefined,
|
||||
placeHolder: definition.placeholder,
|
||||
status: definition.status,
|
||||
obsolete: definition.obsolete,
|
||||
level: definition.level,
|
||||
isHidden: definition.secret,
|
||||
};
|
||||
}
|
||||
|
||||
export class LiveSyncSetting extends Setting {
|
||||
autoWiredComponent?: TextComponent | ToggleComponent | DropdownComponent | ButtonComponent | TextAreaComponent;
|
||||
applyButtonComponent?: ButtonComponent;
|
||||
@@ -74,7 +56,7 @@ export class LiveSyncSetting extends Setting {
|
||||
return this;
|
||||
}
|
||||
autoWireSetting(key: AllSettingItemKey, opt?: AutoWireOption) {
|
||||
const conf = opt?.settingDefinition ? configurationFromDefinition(opt.settingDefinition) : getConfig(key);
|
||||
const conf = getConfig(key);
|
||||
if (!conf) {
|
||||
// throw new Error($msg("liveSyncSetting.errorNoSuchSettingItem", { key }));
|
||||
return;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
LEVEL_ADVANCED,
|
||||
LEVEL_EDGE_CASE,
|
||||
REMOTE_P2P,
|
||||
isJournalRemoteType,
|
||||
} from "@lib/common/types.ts";
|
||||
import { delay, isObjectDifferent, sizeToHumanReadable } from "@lib/common/utils.ts";
|
||||
import { versionNumberString2Number } from "@lib/string_and_binary/convert.ts";
|
||||
@@ -64,7 +65,7 @@ import { panePatches } from "./PanePatches.ts";
|
||||
import { paneMaintenance } from "./PaneMaintenance.ts";
|
||||
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
|
||||
import { JournalSyncCore } from "@lib/replication/journal/JournalSyncCore.js";
|
||||
import { MinioStorageAdapter } from "@lib/replication/journal/objectstore/MinioStorageAdapter.js";
|
||||
import { createJournalStorageAdapter } from "@lib/replication/journal/objectstore/JournalStorageAdapterFactory.js";
|
||||
|
||||
// For creating a document
|
||||
// const toc = new Set<string>();
|
||||
@@ -530,6 +531,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
({
|
||||
visibility: this.isConfiguredAs("remoteType", REMOTE_MINIO),
|
||||
}) as OnUpdateResult;
|
||||
onlyOnJournalSync = () =>
|
||||
({
|
||||
visibility: isJournalRemoteType(this.editingSettings.remoteType),
|
||||
}) as OnUpdateResult;
|
||||
onlyOnOnlyP2P = () =>
|
||||
({
|
||||
visibility: this.isConfiguredAs("remoteType", REMOTE_P2P),
|
||||
@@ -537,11 +542,14 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
onlyOnCouchDBOrMinIO = () =>
|
||||
({
|
||||
visibility:
|
||||
this.isConfiguredAs("remoteType", REMOTE_COUCHDB) || this.isConfiguredAs("remoteType", REMOTE_MINIO),
|
||||
this.isConfiguredAs("remoteType", REMOTE_COUCHDB) ||
|
||||
isJournalRemoteType(this.editingSettings.remoteType),
|
||||
}) as OnUpdateResult;
|
||||
// E2EE Function
|
||||
checkWorkingPassphrase = async (): Promise<boolean> => {
|
||||
if (this.editingSettings.remoteType == REMOTE_MINIO) return true;
|
||||
if (isJournalRemoteType(this.editingSettings.remoteType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const settingForCheck: RemoteDBSettings = {
|
||||
...this.editingSettings,
|
||||
@@ -856,7 +864,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
this.core.settings,
|
||||
this.core.simpleStore,
|
||||
this.core,
|
||||
new MinioStorageAdapter(this.core.settings, this.core)
|
||||
createJournalStorageAdapter(this.core.settings, this.core)
|
||||
);
|
||||
}
|
||||
async resetRemoteBucket() {
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import {
|
||||
getExplicitSettingCommitGroup,
|
||||
getSettingDefinition,
|
||||
type AllBooleanItemKey,
|
||||
type AllNumericItemKey,
|
||||
type AllSettingItemKey,
|
||||
type AllStringItemKey,
|
||||
} from "./settingConstants.ts";
|
||||
import type { LiveSyncSetting } from "./LiveSyncSetting.ts";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import type { AutoWireOption } from "./SettingPane.ts";
|
||||
|
||||
export type ObsidianSettingRenderOption<TOption extends string = string> = AutoWireOption & {
|
||||
options?: Record<TOption, string>;
|
||||
clampMin?: number;
|
||||
clampMax?: number;
|
||||
acceptZero?: boolean;
|
||||
renderInternal?: boolean;
|
||||
};
|
||||
|
||||
export function addObsidianApplyButton(setting: LiveSyncSetting, group: string, text?: string): LiveSyncSetting {
|
||||
const commitGroup = getExplicitSettingCommitGroup(group);
|
||||
if (!commitGroup) {
|
||||
throw new Error(`No explicit setting commit group found for '${group}'`);
|
||||
}
|
||||
return setting.addApplyButton(commitGroup.applyKeys, text);
|
||||
}
|
||||
|
||||
export function renderObsidianApplyButton(containerEl: HTMLElement, group: string, text?: string): LiveSyncSetting {
|
||||
return addObsidianApplyButton(new Setting(containerEl), group, text);
|
||||
}
|
||||
|
||||
export function renderObsidianSetting<TOption extends string = string>(
|
||||
containerEl: HTMLElement,
|
||||
key: AllSettingItemKey,
|
||||
opt: ObsidianSettingRenderOption<TOption> = {}
|
||||
): LiveSyncSetting {
|
||||
const definition = getSettingDefinition(key);
|
||||
if (!definition) {
|
||||
throw new Error(`No setting definition found for '${key}'`);
|
||||
}
|
||||
if (definition.internal && !opt.renderInternal) {
|
||||
throw new Error(`Setting '${key}' is internal and cannot be rendered automatically`);
|
||||
}
|
||||
if (definition.render === "custom" || definition.kind === "custom") {
|
||||
throw new Error(`Setting '${key}' requires a custom renderer`);
|
||||
}
|
||||
|
||||
const isExplicitCommit = definition.commit?.mode === "explicit";
|
||||
const holdValue = opt.holdValue ?? isExplicitCommit;
|
||||
const setting = new Setting(containerEl);
|
||||
const wireOption = {
|
||||
...opt,
|
||||
holdValue,
|
||||
settingDefinition: definition,
|
||||
};
|
||||
|
||||
if (opt.options) {
|
||||
return setting.autoWireDropDown(key as AllStringItemKey, { ...wireOption, options: opt.options });
|
||||
}
|
||||
|
||||
switch (definition.kind) {
|
||||
case "boolean":
|
||||
return setting.autoWireToggle(key as AllBooleanItemKey, wireOption);
|
||||
case "number":
|
||||
return setting.autoWireNumeric(key as AllNumericItemKey, wireOption);
|
||||
case "password":
|
||||
return setting.autoWireText(key as AllStringItemKey, { ...wireOption, isPassword: true });
|
||||
case "textarea":
|
||||
return setting.autoWireTextArea(key as AllStringItemKey, wireOption);
|
||||
case "select":
|
||||
throw new Error(`Setting '${key}' requires select options`);
|
||||
case "text":
|
||||
return setting.autoWireText(key as AllStringItemKey, wireOption);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,41 @@
|
||||
import { ChunkAlgorithmNames } from "@lib/common/types.ts";
|
||||
import { renderObsidianSetting } from "./ObsidianSettingRenderer.ts";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
|
||||
export function paneAdvanced(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
|
||||
void addPanel(paneEl, "Memory cache").then((paneEl) => {
|
||||
renderObsidianSetting(paneEl, "hashCacheMaxCount", { clampMin: 10 });
|
||||
// renderObsidianSetting(paneEl, "hashCacheMaxAmount", { clampMin: 1 });
|
||||
new Setting(paneEl).autoWireNumeric("hashCacheMaxCount", { clampMin: 10 });
|
||||
// new Setting(paneEl).autoWireNumeric("hashCacheMaxAmount", { clampMin: 1 });
|
||||
});
|
||||
void addPanel(paneEl, "Local Database Tweak").then((paneEl) => {
|
||||
paneEl.addClass("wizardHidden");
|
||||
|
||||
const items = ChunkAlgorithmNames;
|
||||
renderObsidianSetting(paneEl, "chunkSplitterVersion", {
|
||||
new Setting(paneEl).autoWireDropDown("chunkSplitterVersion", {
|
||||
options: items,
|
||||
});
|
||||
renderObsidianSetting(paneEl, "customChunkSize", { clampMin: 0, acceptZero: true });
|
||||
new Setting(paneEl).autoWireNumeric("customChunkSize", { clampMin: 0, acceptZero: true });
|
||||
});
|
||||
|
||||
void addPanel(paneEl, "Transfer Tweak").then((paneEl) => {
|
||||
renderObsidianSetting(paneEl, "readChunksOnline", { onUpdate: this.onlyOnCouchDB }).setClass("wizardHidden");
|
||||
renderObsidianSetting(paneEl, "useOnlyLocalChunk", { onUpdate: this.onlyOnCouchDB }).setClass("wizardHidden");
|
||||
new Setting(paneEl)
|
||||
.setClass("wizardHidden")
|
||||
.autoWireToggle("readChunksOnline", { onUpdate: this.onlyOnCouchDB });
|
||||
new Setting(paneEl)
|
||||
.setClass("wizardHidden")
|
||||
.autoWireToggle("useOnlyLocalChunk", { onUpdate: this.onlyOnCouchDB });
|
||||
|
||||
renderObsidianSetting(paneEl, "concurrencyOfReadChunksOnline", {
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireNumeric("concurrencyOfReadChunksOnline", {
|
||||
clampMin: 10,
|
||||
onUpdate: this.onlyOnCouchDB,
|
||||
}).setClass("wizardHidden");
|
||||
});
|
||||
|
||||
renderObsidianSetting(paneEl, "minimumIntervalOfReadChunksOnline", {
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireNumeric("minimumIntervalOfReadChunksOnline", {
|
||||
clampMin: 10,
|
||||
onUpdate: this.onlyOnCouchDB,
|
||||
}).setClass("wizardHidden");
|
||||
renderObsidianSetting(paneEl, "autoAcceptCompatibleTweak").setClass("wizardHidden");
|
||||
});
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("autoAcceptCompatibleTweak");
|
||||
// new Setting(paneEl)
|
||||
// .setClass("wizardHidden")
|
||||
// .autoWireToggle("sendChunksBulk", { onUpdate: onlyOnCouchDB })
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import { EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG, eventHub } from "@/common/events.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import { renderObsidianSetting } from "./ObsidianSettingRenderer.ts";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
import { enableOnly, visibleOnly } from "./SettingPane.ts";
|
||||
export function paneCustomisationSync(
|
||||
@@ -36,27 +35,27 @@ export function paneCustomisationSync(
|
||||
visibleOnly(() => this.isConfiguredAs("usePluginSync", true))
|
||||
);
|
||||
|
||||
renderObsidianSetting(paneEl, "deviceAndVaultName", {
|
||||
new Setting(paneEl).autoWireText("deviceAndVaultName", {
|
||||
placeHolder: "desktop",
|
||||
onUpdate: enableOnlyOnPluginSyncIsNotEnabled,
|
||||
});
|
||||
|
||||
renderObsidianSetting(paneEl, "usePluginSyncV2");
|
||||
new Setting(paneEl).autoWireToggle("usePluginSyncV2");
|
||||
|
||||
renderObsidianSetting(paneEl, "usePluginSync", {
|
||||
new Setting(paneEl).autoWireToggle("usePluginSync", {
|
||||
onUpdate: enableOnly(() => !this.isConfiguredAs("deviceAndVaultName", "")),
|
||||
});
|
||||
|
||||
renderObsidianSetting(paneEl, "autoSweepPlugins", {
|
||||
new Setting(paneEl).autoWireToggle("autoSweepPlugins", {
|
||||
onUpdate: visibleOnlyOnPluginSyncEnabled,
|
||||
});
|
||||
|
||||
renderObsidianSetting(paneEl, "autoSweepPluginsPeriodic", {
|
||||
new Setting(paneEl).autoWireToggle("autoSweepPluginsPeriodic", {
|
||||
onUpdate: visibleOnly(
|
||||
() => this.isConfiguredAs("usePluginSync", true) && this.isConfiguredAs("autoSweepPlugins", true)
|
||||
),
|
||||
});
|
||||
renderObsidianSetting(paneEl, "notifyPluginOrSettingUpdated", {
|
||||
new Setting(paneEl).autoWireToggle("notifyPluginOrSettingUpdated", {
|
||||
onUpdate: visibleOnlyOnPluginSyncEnabled,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { $msg, $t } from "@lib/common/i18n.ts";
|
||||
import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@lib/common/rosetta.ts";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import { renderObsidianSetting } from "./ObsidianSettingRenderer.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
import { visibleOnly } from "./SettingPane.ts";
|
||||
@@ -17,20 +16,20 @@ export function paneGeneral(
|
||||
// ["", $msg("obsidianLiveSyncSettingTab.defaultLanguage")],
|
||||
...SUPPORTED_I18N_LANGS.map((e) => [e, $t(`lang-${e}`)]),
|
||||
]) as Record<I18N_LANGS, string>;
|
||||
renderObsidianSetting(paneEl, "displayLanguage", {
|
||||
new Setting(paneEl).autoWireDropDown("displayLanguage", {
|
||||
options: languages,
|
||||
});
|
||||
this.addOnSaved("displayLanguage", () => this.display());
|
||||
renderObsidianSetting(paneEl, "showStatusOnEditor");
|
||||
new Setting(paneEl).autoWireToggle("showStatusOnEditor");
|
||||
this.addOnSaved("showStatusOnEditor", () => {
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
});
|
||||
renderObsidianSetting(paneEl, "showOnlyIconsOnEditor", {
|
||||
new Setting(paneEl).autoWireToggle("showOnlyIconsOnEditor", {
|
||||
onUpdate: visibleOnly(() => this.isConfiguredAs("showStatusOnEditor", true)),
|
||||
});
|
||||
renderObsidianSetting(paneEl, "showStatusOnStatusbar");
|
||||
renderObsidianSetting(paneEl, "hideFileWarningNotice");
|
||||
renderObsidianSetting(paneEl, "networkWarningStyle", {
|
||||
new Setting(paneEl).autoWireToggle("showStatusOnStatusbar");
|
||||
new Setting(paneEl).autoWireToggle("hideFileWarningNotice");
|
||||
new Setting(paneEl).autoWireDropDown("networkWarningStyle", {
|
||||
options: {
|
||||
[NetworkWarningStyles.BANNER]: "Show full banner",
|
||||
[NetworkWarningStyles.ICON]: "Show icon only",
|
||||
@@ -44,9 +43,9 @@ export function paneGeneral(
|
||||
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleLogging")).then((paneEl) => {
|
||||
paneEl.addClass("wizardHidden");
|
||||
|
||||
renderObsidianSetting(paneEl, "lessInformationInLog");
|
||||
new Setting(paneEl).autoWireToggle("lessInformationInLog");
|
||||
|
||||
renderObsidianSetting(paneEl, "showVerboseLog", {
|
||||
new Setting(paneEl).autoWireToggle("showVerboseLog", {
|
||||
onUpdate: visibleOnly(() => this.isConfiguredAs("lessInformationInLog", false)),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,6 @@ import { ICHeader, ICXHeader, PSCHeader } from "@/common/types.ts";
|
||||
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { EVENT_REQUEST_SHOW_HISTORY } from "@/common/obsidianEvents.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import { renderObsidianSetting } from "./ObsidianSettingRenderer.ts";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
import { isNotFoundError } from "@lib/common/utils.doc.ts";
|
||||
export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
|
||||
@@ -88,14 +87,14 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
eventHub.emitEvent(EVENT_REQUEST_CHECK_REMOTE_SIZE);
|
||||
})
|
||||
);
|
||||
renderObsidianSetting(paneEl, "writeLogToTheFile");
|
||||
new Setting(paneEl).autoWireToggle("writeLogToTheFile");
|
||||
});
|
||||
|
||||
void addPanel(paneEl, "Scram Switches").then((paneEl) => {
|
||||
renderObsidianSetting(paneEl, "suspendFileWatching");
|
||||
new Setting(paneEl).autoWireToggle("suspendFileWatching");
|
||||
this.addOnSaved("suspendFileWatching", () => this.services.appLifecycle.askRestart());
|
||||
|
||||
renderObsidianSetting(paneEl, "suspendParseReplicationResult");
|
||||
new Setting(paneEl).autoWireToggle("suspendParseReplicationResult");
|
||||
this.addOnSaved("suspendParseReplicationResult", () => this.services.appLifecycle.askRestart());
|
||||
});
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ export function paneMaintenance(
|
||||
Logger(`Journal received history has been cleared.`, LOG_LEVEL_NOTICE);
|
||||
})
|
||||
)
|
||||
.addOnUpdate(this.onlyOnMinIO);
|
||||
.addOnUpdate(this.onlyOnJournalSync);
|
||||
|
||||
new Setting(paneEl)
|
||||
.setName("Reset journal sent history")
|
||||
@@ -185,7 +185,7 @@ export function paneMaintenance(
|
||||
Logger(`Journal sent history has been cleared.`, LOG_LEVEL_NOTICE);
|
||||
})
|
||||
)
|
||||
.addOnUpdate(this.onlyOnMinIO);
|
||||
.addOnUpdate(this.onlyOnJournalSync);
|
||||
});
|
||||
void addPanel(paneEl, "Garbage Collection V3 (Beta)", (e) => e, this.onlyOnP2POrCouchDB).then((paneEl) => {
|
||||
new Setting(paneEl)
|
||||
@@ -336,7 +336,7 @@ export function paneMaintenance(
|
||||
Logger(`Journal exchange history has been cleared.`, LOG_LEVEL_NOTICE);
|
||||
})
|
||||
)
|
||||
.addOnUpdate(this.onlyOnMinIO);
|
||||
.addOnUpdate(this.onlyOnJournalSync);
|
||||
|
||||
new Setting(paneEl)
|
||||
.setName("Purge all journal counter")
|
||||
@@ -351,7 +351,7 @@ export function paneMaintenance(
|
||||
Logger(`Journal download/upload cache has been cleared.`, LOG_LEVEL_NOTICE);
|
||||
})
|
||||
)
|
||||
.addOnUpdate(this.onlyOnMinIO);
|
||||
.addOnUpdate(this.onlyOnJournalSync);
|
||||
|
||||
new Setting(paneEl)
|
||||
.setName("Fresh Start Wipe")
|
||||
@@ -374,7 +374,7 @@ export function paneMaintenance(
|
||||
Logger(`Deleted all data on remote server`, LOG_LEVEL_NOTICE);
|
||||
})
|
||||
)
|
||||
.addOnUpdate(this.onlyOnMinIO);
|
||||
.addOnUpdate(this.onlyOnJournalSync);
|
||||
});
|
||||
|
||||
void addPanel(paneEl, "Reset").then((paneEl) => {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from "@lib/common/types.ts";
|
||||
import { Logger } from "@lib/common/logger.ts";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import { addObsidianApplyButton, renderObsidianApplyButton, renderObsidianSetting } from "./ObsidianSettingRenderer.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
import { visibleOnly } from "./SettingPane.ts";
|
||||
@@ -17,17 +16,17 @@ import { migrateDatabases } from "./settingUtils.ts";
|
||||
|
||||
export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
|
||||
void addPanel(paneEl, "Compatibility (Metadata)").then((paneEl) => {
|
||||
renderObsidianSetting(paneEl, "deleteMetadataOfDeletedFiles").setClass("wizardHidden");
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("deleteMetadataOfDeletedFiles");
|
||||
|
||||
renderObsidianSetting(paneEl, "automaticallyDeleteMetadataOfDeletedFiles", {
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireNumeric("automaticallyDeleteMetadataOfDeletedFiles", {
|
||||
onUpdate: visibleOnly(() => this.isConfiguredAs("deleteMetadataOfDeletedFiles", true)),
|
||||
}).setClass("wizardHidden");
|
||||
});
|
||||
});
|
||||
|
||||
void addPanel(paneEl, "Compatibility (Conflict Behaviour)").then((paneEl) => {
|
||||
paneEl.addClass("wizardHidden");
|
||||
renderObsidianSetting(paneEl, "disableMarkdownAutoMerge").setClass("wizardHidden");
|
||||
renderObsidianSetting(paneEl, "writeDocumentsIfConflicted").setClass("wizardHidden");
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("disableMarkdownAutoMerge");
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("writeDocumentsIfConflicted");
|
||||
});
|
||||
|
||||
void addPanel(paneEl, "Compatibility (Database structure)").then((paneEl) => {
|
||||
@@ -114,18 +113,18 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
|
||||
});
|
||||
}
|
||||
}
|
||||
renderObsidianSetting(paneEl, "handleFilenameCaseSensitive", { holdValue: true }).setClass("wizardHidden");
|
||||
new Setting(paneEl).autoWireToggle("handleFilenameCaseSensitive", { holdValue: true }).setClass("wizardHidden");
|
||||
});
|
||||
|
||||
void addPanel(paneEl, "Compatibility (Internal API Usage)").then((paneEl) => {
|
||||
renderObsidianSetting(paneEl, "watchInternalFileChanges", { invert: true });
|
||||
new Setting(paneEl).autoWireToggle("watchInternalFileChanges", { invert: true });
|
||||
});
|
||||
void addPanel(paneEl, "Compatibility (Remote Database)").then((paneEl) => {
|
||||
renderObsidianSetting(paneEl, "E2EEAlgorithm", {
|
||||
new Setting(paneEl).autoWireDropDown("E2EEAlgorithm", {
|
||||
options: E2EEAlgorithmNames,
|
||||
});
|
||||
});
|
||||
renderObsidianSetting(paneEl, "useDynamicIterationCount", {
|
||||
new Setting(paneEl).autoWireToggle("useDynamicIterationCount", {
|
||||
holdValue: true,
|
||||
onUpdate: visibleOnly(
|
||||
() =>
|
||||
@@ -135,15 +134,16 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
|
||||
});
|
||||
|
||||
void addPanel(paneEl, "Edge case addressing (Database)").then((paneEl) => {
|
||||
renderObsidianSetting(paneEl, "additionalSuffixOfDatabaseName");
|
||||
renderObsidianApplyButton(paneEl, "database-suffix");
|
||||
new Setting(paneEl)
|
||||
.autoWireText("additionalSuffixOfDatabaseName", { holdValue: true })
|
||||
.addApplyButton(["additionalSuffixOfDatabaseName"]);
|
||||
|
||||
this.addOnSaved("additionalSuffixOfDatabaseName", async (key) => {
|
||||
Logger("Suffix has been changed. Reopening database...", LOG_LEVEL_NOTICE);
|
||||
await this.services.databaseEvents.initialiseDatabase();
|
||||
});
|
||||
|
||||
renderObsidianSetting(paneEl, "hashAlg", {
|
||||
new Setting(paneEl).autoWireDropDown("hashAlg", {
|
||||
options: {
|
||||
"": "Old Algorithm",
|
||||
xxhash32: "xxhash32 (Fast but less collision resistance)",
|
||||
@@ -157,15 +157,15 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
|
||||
});
|
||||
});
|
||||
void addPanel(paneEl, "Edge case addressing (Behaviour)").then((paneEl) => {
|
||||
renderObsidianSetting(paneEl, "doNotSuspendOnFetching");
|
||||
renderObsidianSetting(paneEl, "doNotDeleteFolder").setClass("wizardHidden");
|
||||
renderObsidianSetting(paneEl, "processSizeMismatchedFiles");
|
||||
new Setting(paneEl).autoWireToggle("doNotSuspendOnFetching");
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("doNotDeleteFolder");
|
||||
new Setting(paneEl).autoWireToggle("processSizeMismatchedFiles");
|
||||
});
|
||||
|
||||
void addPanel(paneEl, "Edge case addressing (Processing)").then((paneEl) => {
|
||||
renderObsidianSetting(paneEl, "disableWorkerForGeneratingChunks");
|
||||
new Setting(paneEl).autoWireToggle("disableWorkerForGeneratingChunks");
|
||||
|
||||
renderObsidianSetting(paneEl, "processSmallFilesInUIThread", {
|
||||
new Setting(paneEl).autoWireToggle("processSmallFilesInUIThread", {
|
||||
onUpdate: visibleOnly(() => this.isConfiguredAs("disableWorkerForGeneratingChunks", false)),
|
||||
});
|
||||
});
|
||||
@@ -173,11 +173,11 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
|
||||
// new Setting(paneEl).autoWireToggle("useRequestAPI");
|
||||
// });
|
||||
void addPanel(paneEl, "Compatibility (Trouble addressed)").then((paneEl) => {
|
||||
renderObsidianSetting(paneEl, "disableCheckingConfigMismatch");
|
||||
new Setting(paneEl).autoWireToggle("disableCheckingConfigMismatch");
|
||||
});
|
||||
void addPanel(paneEl, "Remediation").then((paneEl) => {
|
||||
let dateEl: HTMLSpanElement;
|
||||
const remediationSetting = new Setting(paneEl)
|
||||
new Setting(paneEl)
|
||||
.addText((text) => {
|
||||
const updateDateText = () => {
|
||||
if (this.editingSettings.maxMTimeForReflectEvents == 0) {
|
||||
@@ -212,8 +212,8 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
|
||||
updateDateText();
|
||||
return text;
|
||||
})
|
||||
.setAuto("maxMTimeForReflectEvents");
|
||||
addObsidianApplyButton(remediationSetting, "remediation-reflect-events");
|
||||
.setAuto("maxMTimeForReflectEvents")
|
||||
.addApplyButton(["maxMTimeForReflectEvents"]);
|
||||
|
||||
this.addOnSaved("maxMTimeForReflectEvents", async (key) => {
|
||||
const buttons = ["Restart Now", "Later"] as const;
|
||||
@@ -240,6 +240,6 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
|
||||
// .setClass("wizardHidden");
|
||||
// new Setting(paneEl).autoWireNumeric("maxAgeInEden", { onUpdate: onlyUsingEden }).setClass("wizardHidden");
|
||||
|
||||
renderObsidianSetting(paneEl, "enableCompression").setClass("wizardHidden");
|
||||
new Setting(paneEl).autoWireToggle("enableCompression").setClass("wizardHidden");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type ConfigPassphraseStore } from "@lib/common/types.ts";
|
||||
import { renderObsidianApplyButton, renderObsidianSetting } from "./ObsidianSettingRenderer.ts";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
|
||||
@@ -21,14 +21,14 @@ export function panePowerUsers(
|
||||
this.onlyOnCouchDB
|
||||
).addClass("wizardHidden");
|
||||
|
||||
renderObsidianSetting(paneEl, "batch_size", { clampMin: 2, onUpdate: this.onlyOnCouchDB }).setClass(
|
||||
"wizardHidden"
|
||||
);
|
||||
renderObsidianSetting(paneEl, "batches_limit", {
|
||||
new Setting(paneEl)
|
||||
.setClass("wizardHidden")
|
||||
.autoWireNumeric("batch_size", { clampMin: 2, onUpdate: this.onlyOnCouchDB });
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireNumeric("batches_limit", {
|
||||
clampMin: 2,
|
||||
onUpdate: this.onlyOnCouchDB,
|
||||
}).setClass("wizardHidden");
|
||||
renderObsidianSetting(paneEl, "useTimeouts", { onUpdate: this.onlyOnCouchDB }).setClass("wizardHidden");
|
||||
});
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("useTimeouts", { onUpdate: this.onlyOnCouchDB });
|
||||
});
|
||||
void addPanel(paneEl, "Configuration Encryption").then((paneEl) => {
|
||||
const passphrase_options: Record<ConfigPassphraseStore, string> = {
|
||||
@@ -37,18 +37,23 @@ export function panePowerUsers(
|
||||
ASK_AT_LAUNCH: "Ask an passphrase at every launch",
|
||||
};
|
||||
|
||||
renderObsidianSetting(paneEl, "configPassphraseStore", {
|
||||
options: passphrase_options,
|
||||
}).setClass("wizardHidden");
|
||||
new Setting(paneEl)
|
||||
.setName("Encrypting sensitive configuration items")
|
||||
.autoWireDropDown("configPassphraseStore", {
|
||||
options: passphrase_options,
|
||||
holdValue: true,
|
||||
})
|
||||
.setClass("wizardHidden");
|
||||
|
||||
renderObsidianSetting(paneEl, "configPassphrase")
|
||||
new Setting(paneEl)
|
||||
.autoWireText("configPassphrase", { isPassword: true, holdValue: true })
|
||||
.setClass("wizardHidden")
|
||||
.addOnUpdate(() => ({
|
||||
disabled: !this.isConfiguredAs("configPassphraseStore", "LOCALSTORAGE"),
|
||||
}));
|
||||
renderObsidianApplyButton(paneEl, "configuration-encryption").setClass("wizardHidden");
|
||||
new Setting(paneEl).addApplyButton(["configPassphrase", "configPassphraseStore"]).setClass("wizardHidden");
|
||||
});
|
||||
void addPanel(paneEl, "Developer").then((paneEl) => {
|
||||
renderObsidianSetting(paneEl, "enableDebugTools").setClass("wizardHidden");
|
||||
new Setting(paneEl).autoWireToggle("enableDebugTools").setClass("wizardHidden");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
REMOTE_WEBDAV,
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_NOTICE,
|
||||
type ObsidianLiveSyncSettings,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
import { Menu, type ButtonComponent } from "@/deps.ts";
|
||||
import { $msg } from "@lib/common/i18n.ts";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import { renderObsidianSetting } from "./ObsidianSettingRenderer.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
// import { visibleOnly } from "./SettingPane.ts";
|
||||
@@ -34,7 +34,9 @@ import SetupRemote from "@/modules/features/SetupWizard/dialogs/SetupRemote.svel
|
||||
import SetupRemoteCouchDB from "@/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
|
||||
import SetupRemoteBucket from "@/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte";
|
||||
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
|
||||
import SetupRemoteWebDAV from "@/modules/features/SetupWizard/dialogs/SetupRemoteWebDAV.svelte";
|
||||
import { syncActivatedRemoteSettings } from "./remoteConfigBuffer.ts";
|
||||
import { getRemoteConfigurationDescription } from "./remoteConfigDisplay.ts";
|
||||
|
||||
function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianLiveSyncSettings {
|
||||
const workObj = { ...editingSettings } as ObsidianLiveSyncSettings;
|
||||
@@ -70,6 +72,9 @@ function serializeRemoteConfiguration(settings: ObsidianLiveSyncSettings): strin
|
||||
if (settings.remoteType === REMOTE_P2P) {
|
||||
return ConnectionStringParser.serialize({ type: "p2p", settings });
|
||||
}
|
||||
if (settings.remoteType === REMOTE_WEBDAV) {
|
||||
return ConnectionStringParser.serialize({ type: "webdav", settings });
|
||||
}
|
||||
return ConnectionStringParser.serialize({ type: "couchdb", settings });
|
||||
}
|
||||
|
||||
@@ -93,6 +98,14 @@ function suggestRemoteConfigurationName(parsed: RemoteConfigurationResult): stri
|
||||
if (parsed.type === "s3") {
|
||||
return `S3 ${parsed.settings.bucket || parsed.settings.endpoint}`;
|
||||
}
|
||||
if (parsed.type === "webdav") {
|
||||
try {
|
||||
const url = new URL(parsed.settings.webDAVactiveConnectionURI.replace(/^sls\+webdav:/, "https:"));
|
||||
return `WebDAV ${url.host}`;
|
||||
} catch {
|
||||
return "Imported WebDAV";
|
||||
}
|
||||
}
|
||||
return `P2P ${parsed.settings.P2P_roomID || "Remote"}`;
|
||||
}
|
||||
|
||||
@@ -199,7 +212,7 @@ export function paneRemoteConfig(
|
||||
};
|
||||
const runRemoteSetup = async (
|
||||
baseSettings: ObsidianLiveSyncSettings,
|
||||
remoteType?: typeof REMOTE_COUCHDB | typeof REMOTE_MINIO | typeof REMOTE_P2P
|
||||
remoteType?: typeof REMOTE_COUCHDB | typeof REMOTE_MINIO | typeof REMOTE_P2P | typeof REMOTE_WEBDAV
|
||||
): Promise<ObsidianLiveSyncSettings | false> => {
|
||||
const setupManager = this.core.getModule(SetupManager);
|
||||
const dialogManager = setupManager.dialogManager;
|
||||
@@ -211,7 +224,13 @@ export function paneRemoteConfig(
|
||||
return false;
|
||||
}
|
||||
targetRemoteType =
|
||||
method === "bucket" ? REMOTE_MINIO : method === "p2p" ? REMOTE_P2P : REMOTE_COUCHDB;
|
||||
method === "bucket"
|
||||
? REMOTE_MINIO
|
||||
: method === "p2p"
|
||||
? REMOTE_P2P
|
||||
: method === "webdav"
|
||||
? REMOTE_WEBDAV
|
||||
: REMOTE_COUCHDB;
|
||||
}
|
||||
|
||||
if (targetRemoteType === REMOTE_MINIO) {
|
||||
@@ -230,6 +249,14 @@ export function paneRemoteConfig(
|
||||
return { ...baseSettings, ...p2pConf, remoteType: REMOTE_P2P };
|
||||
}
|
||||
|
||||
if (targetRemoteType === REMOTE_WEBDAV) {
|
||||
const webDAVConf = await dialogManager.openWithExplicitCancel(SetupRemoteWebDAV, baseSettings);
|
||||
if (webDAVConf === "cancelled" || typeof webDAVConf !== "object") {
|
||||
return false;
|
||||
}
|
||||
return { ...baseSettings, ...webDAVConf, remoteType: REMOTE_WEBDAV };
|
||||
}
|
||||
|
||||
const couchConf = await dialogManager.openWithExplicitCancel(SetupRemoteCouchDB, baseSettings);
|
||||
if (couchConf === "cancelled" || typeof couchConf !== "object") {
|
||||
return false;
|
||||
@@ -332,7 +359,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(getRemoteConfigurationDescription(config.uri));
|
||||
|
||||
if (config.id === this.editingSettings.activeConfigurationId) {
|
||||
row.nameEl.addClass("sls-active-remote-name");
|
||||
@@ -357,6 +384,8 @@ export function paneRemoteConfig(
|
||||
workSettings.remoteType = REMOTE_COUCHDB;
|
||||
} else if (parsed.type === "s3") {
|
||||
workSettings.remoteType = REMOTE_MINIO;
|
||||
} else if (parsed.type === "webdav") {
|
||||
workSettings.remoteType = REMOTE_WEBDAV;
|
||||
} else {
|
||||
workSettings.remoteType = REMOTE_P2P;
|
||||
}
|
||||
@@ -467,6 +496,8 @@ export function paneRemoteConfig(
|
||||
workSettings.remoteType = REMOTE_COUCHDB;
|
||||
} else if (parsed.type === "s3") {
|
||||
workSettings.remoteType = REMOTE_MINIO;
|
||||
} else if (parsed.type === "webdav") {
|
||||
workSettings.remoteType = REMOTE_WEBDAV;
|
||||
} else {
|
||||
workSettings.remoteType = REMOTE_P2P;
|
||||
}
|
||||
@@ -675,7 +706,7 @@ export function paneRemoteConfig(
|
||||
|
||||
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleNotification"), () => {}).then((paneEl) => {
|
||||
paneEl.addClass("wizardHidden");
|
||||
renderObsidianSetting(paneEl, "notifyThresholdOfRemoteStorageSize", {}).setClass("wizardHidden");
|
||||
new Setting(paneEl).autoWireNumeric("notifyThresholdOfRemoteStorageSize", {}).setClass("wizardHidden");
|
||||
});
|
||||
|
||||
// new Setting(paneEl).setClass("wizardOnly").addButton((button) =>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getRemoteConfigurationDescription } from "./remoteConfigDisplay";
|
||||
|
||||
describe("getRemoteConfigurationDescription", () => {
|
||||
it("should mask credentials and query parameters in WebDAV connection strings", () => {
|
||||
expect(
|
||||
getRemoteConfigurationDescription(
|
||||
"sls+webdav://user:pass@example.com/dav?prefix=vault%2F&headers=Authorization%3A%20Bearer%20token"
|
||||
)
|
||||
).toBe("https://example.com/dav");
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import MultipleRegExpControl from "./MultipleRegExpControl.svelte";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import { mount } from "svelte";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import { renderObsidianSetting } from "./ObsidianSettingRenderer.ts";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
import { visibleOnly } from "./SettingPane.ts";
|
||||
export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
|
||||
@@ -47,12 +46,12 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
||||
},
|
||||
},
|
||||
});
|
||||
renderObsidianSetting(paneEl, "syncMaxSizeInMB", { clampMin: 0 }).setClass("wizardHidden");
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireNumeric("syncMaxSizeInMB", { clampMin: 0 });
|
||||
|
||||
renderObsidianSetting(paneEl, "useIgnoreFiles").setClass("wizardHidden");
|
||||
renderObsidianSetting(paneEl, "ignoreFiles", {
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("useIgnoreFiles");
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireTextArea("ignoreFiles", {
|
||||
onUpdate: visibleOnly(() => this.isConfiguredAs("useIgnoreFiles", true)),
|
||||
}).setClass("wizardHidden");
|
||||
});
|
||||
});
|
||||
void addPanel(paneEl, "Hidden Files", undefined, undefined, LEVEL_ADVANCED).then((paneEl) => {
|
||||
const targetPatternSetting = new Setting(paneEl)
|
||||
|
||||
@@ -15,7 +15,6 @@ import { DEFAULT_SETTINGS } from "@lib/common/types.ts";
|
||||
import { request } from "@/deps.ts";
|
||||
import { SetupManager, UserMode } from "@/modules/features/SetupManager.ts";
|
||||
import { LiveSyncError } from "@lib/common/LSError.ts";
|
||||
import { renderObsidianSetting } from "./ObsidianSettingRenderer.ts";
|
||||
export function paneSetup(
|
||||
this: ObsidianLiveSyncSettingTab,
|
||||
paneEl: HTMLElement,
|
||||
@@ -108,9 +107,10 @@ export function paneSetup(
|
||||
});
|
||||
|
||||
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleExtraFeatures")).then((paneEl) => {
|
||||
renderObsidianSetting(paneEl, "useAdvancedMode");
|
||||
renderObsidianSetting(paneEl, "usePowerUserMode");
|
||||
renderObsidianSetting(paneEl, "useEdgeCaseMode");
|
||||
new Setting(paneEl).autoWireToggle("useAdvancedMode");
|
||||
|
||||
new Setting(paneEl).autoWireToggle("usePowerUserMode");
|
||||
new Setting(paneEl).autoWireToggle("useEdgeCaseMode");
|
||||
|
||||
this.addOnSaved("useAdvancedMode", () => this.display());
|
||||
this.addOnSaved("usePowerUserMode", () => this.display());
|
||||
|
||||
@@ -2,7 +2,6 @@ import { type ObsidianLiveSyncSettings, LOG_LEVEL_NOTICE, REMOTE_COUCHDB, LEVEL_
|
||||
import { Logger } from "@lib/common/logger.ts";
|
||||
import { $msg } from "@lib/common/i18n.ts";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import { renderObsidianApplyButton, renderObsidianSetting } from "./ObsidianSettingRenderer.ts";
|
||||
import { EVENT_REQUEST_COPY_SETUP_URI, eventHub } from "@/common/events.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
@@ -32,16 +31,18 @@ export function paneSyncSettings(
|
||||
DISABLE: $msg("obsidianLiveSyncSettingTab.optionDisableAllAutomatic"),
|
||||
};
|
||||
|
||||
renderObsidianSetting(paneEl, "preset", {
|
||||
options: options,
|
||||
holdValue: true,
|
||||
}).addButton((button) => {
|
||||
button.setButtonText($msg("obsidianLiveSyncSettingTab.btnApply"));
|
||||
button.onClick(async () => {
|
||||
// await this.saveSettings(["preset"]);
|
||||
await this.saveAllDirtySettings();
|
||||
new Setting(paneEl)
|
||||
.autoWireDropDown("preset", {
|
||||
options: options,
|
||||
holdValue: true,
|
||||
})
|
||||
.addButton((button) => {
|
||||
button.setButtonText($msg("obsidianLiveSyncSettingTab.btnApply"));
|
||||
button.onClick(async () => {
|
||||
// await this.saveSettings(["preset"]);
|
||||
await this.saveAllDirtySettings();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
this.addOnSaved("preset", async (currentPreset) => {
|
||||
if (currentPreset == "") {
|
||||
@@ -135,7 +136,7 @@ export function paneSyncSettings(
|
||||
const onlyOnNonLiveSync = visibleOnly(() => !this.isConfiguredAs("syncMode", "LIVESYNC"));
|
||||
const onlyOnPeriodic = visibleOnly(() => this.isConfiguredAs("syncMode", "PERIODIC"));
|
||||
|
||||
const optionsSyncMode: Record<string, string> =
|
||||
const optionsSyncMode =
|
||||
this.editingSettings.remoteType == REMOTE_COUCHDB
|
||||
? {
|
||||
ONEVENTS: $msg("obsidianLiveSyncSettingTab.optionOnEvents"),
|
||||
@@ -147,9 +148,12 @@ export function paneSyncSettings(
|
||||
PERIODIC: $msg("obsidianLiveSyncSettingTab.optionPeriodicAndEvents"),
|
||||
};
|
||||
|
||||
renderObsidianSetting(paneEl, "syncMode", {
|
||||
options: optionsSyncMode,
|
||||
}).setClass("wizardHidden");
|
||||
new Setting(paneEl)
|
||||
.autoWireDropDown("syncMode", {
|
||||
//@ts-ignore
|
||||
options: optionsSyncMode,
|
||||
})
|
||||
.setClass("wizardHidden");
|
||||
this.addOnSaved("syncMode", async (value) => {
|
||||
this.editingSettings.liveSync = false;
|
||||
this.editingSettings.periodicReplication = false;
|
||||
@@ -163,28 +167,32 @@ export function paneSyncSettings(
|
||||
await this.services.control.applySettings();
|
||||
});
|
||||
|
||||
renderObsidianSetting(paneEl, "periodicReplicationInterval", {
|
||||
clampMax: 5000,
|
||||
onUpdate: onlyOnPeriodic,
|
||||
}).setClass("wizardHidden");
|
||||
new Setting(paneEl)
|
||||
.autoWireNumeric("periodicReplicationInterval", {
|
||||
clampMax: 5000,
|
||||
onUpdate: onlyOnPeriodic,
|
||||
})
|
||||
.setClass("wizardHidden");
|
||||
|
||||
renderObsidianSetting(paneEl, "syncMinimumInterval", {
|
||||
new Setting(paneEl).autoWireNumeric("syncMinimumInterval", {
|
||||
onUpdate: onlyOnNonLiveSync,
|
||||
});
|
||||
renderObsidianSetting(paneEl, "syncOnSave", { onUpdate: onlyOnNonLiveSync }).setClass("wizardHidden");
|
||||
renderObsidianSetting(paneEl, "syncOnEditorSave", { onUpdate: onlyOnNonLiveSync }).setClass("wizardHidden");
|
||||
renderObsidianSetting(paneEl, "syncOnFileOpen", { onUpdate: onlyOnNonLiveSync }).setClass("wizardHidden");
|
||||
renderObsidianSetting(paneEl, "syncOnStart", { onUpdate: onlyOnNonLiveSync }).setClass("wizardHidden");
|
||||
renderObsidianSetting(paneEl, "syncAfterMerge", { onUpdate: onlyOnNonLiveSync }).setClass("wizardHidden");
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("syncOnSave", { onUpdate: onlyOnNonLiveSync });
|
||||
new Setting(paneEl)
|
||||
.setClass("wizardHidden")
|
||||
.autoWireToggle("syncOnEditorSave", { onUpdate: onlyOnNonLiveSync });
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("syncOnFileOpen", { onUpdate: onlyOnNonLiveSync });
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("syncOnStart", { onUpdate: onlyOnNonLiveSync });
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("syncAfterMerge", { onUpdate: onlyOnNonLiveSync });
|
||||
// Desktop app only, and only for the sync modes that keep a background replication channel
|
||||
// (LiveSync and Periodic). Ignored on mobile, where suspending preserves battery. The
|
||||
// visibility predicate mirrors the runtime guard in ModuleObsidianEvents.
|
||||
if (!this.services.API.isMobile()) {
|
||||
renderObsidianSetting(paneEl, "keepReplicationActiveInBackground", {
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("keepReplicationActiveInBackground", {
|
||||
onUpdate: visibleOnly(
|
||||
() => this.isConfiguredAs("syncMode", "LIVESYNC") || this.isConfiguredAs("syncMode", "PERIODIC")
|
||||
),
|
||||
}).setClass("wizardHidden");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -195,15 +203,15 @@ export function paneSyncSettings(
|
||||
visibleOnly(() => !this.isConfiguredAs("syncMode", "LIVESYNC"))
|
||||
).then((paneEl) => {
|
||||
paneEl.addClass("wizardHidden");
|
||||
renderObsidianSetting(paneEl, "batchSave").setClass("wizardHidden");
|
||||
renderObsidianSetting(paneEl, "batchSaveMinimumDelay", {
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("batchSave");
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireNumeric("batchSaveMinimumDelay", {
|
||||
acceptZero: true,
|
||||
onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)),
|
||||
}).setClass("wizardHidden");
|
||||
renderObsidianSetting(paneEl, "batchSaveMaximumDelay", {
|
||||
});
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireNumeric("batchSaveMaximumDelay", {
|
||||
acceptZero: true,
|
||||
onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)),
|
||||
}).setClass("wizardHidden");
|
||||
});
|
||||
});
|
||||
|
||||
void addPanel(
|
||||
@@ -214,9 +222,9 @@ export function paneSyncSettings(
|
||||
LEVEL_ADVANCED
|
||||
).then((paneEl) => {
|
||||
paneEl.addClass("wizardHidden");
|
||||
renderObsidianSetting(paneEl, "trashInsteadDelete").setClass("wizardHidden");
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("trashInsteadDelete");
|
||||
|
||||
renderObsidianSetting(paneEl, "doNotDeleteFolder").setClass("wizardHidden");
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("doNotDeleteFolder");
|
||||
});
|
||||
void addPanel(
|
||||
paneEl,
|
||||
@@ -227,11 +235,11 @@ export function paneSyncSettings(
|
||||
).then((paneEl) => {
|
||||
paneEl.addClass("wizardHidden");
|
||||
|
||||
renderObsidianSetting(paneEl, "resolveConflictsByNewerFile").setClass("wizardHidden");
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("resolveConflictsByNewerFile");
|
||||
|
||||
renderObsidianSetting(paneEl, "checkConflictOnlyOnOpen").setClass("wizardHidden");
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("checkConflictOnlyOnOpen");
|
||||
|
||||
renderObsidianSetting(paneEl, "showMergeDialogOnlyOnActive").setClass("wizardHidden");
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("showMergeDialogOnlyOnActive");
|
||||
});
|
||||
|
||||
void addPanel(
|
||||
@@ -242,12 +250,11 @@ export function paneSyncSettings(
|
||||
LEVEL_ADVANCED
|
||||
).then((paneEl) => {
|
||||
paneEl.addClass("wizardHidden");
|
||||
renderObsidianSetting(paneEl, "settingSyncFile");
|
||||
renderObsidianApplyButton(paneEl, "setting-sync-file");
|
||||
new Setting(paneEl).autoWireText("settingSyncFile", { holdValue: true }).addApplyButton(["settingSyncFile"]);
|
||||
|
||||
renderObsidianSetting(paneEl, "writeCredentialsForSettingSync");
|
||||
new Setting(paneEl).autoWireToggle("writeCredentialsForSettingSync");
|
||||
|
||||
renderObsidianSetting(paneEl, "notifyAllSettingSyncFile");
|
||||
new Setting(paneEl).autoWireToggle("notifyAllSettingSyncFile");
|
||||
});
|
||||
|
||||
void addPanel(
|
||||
@@ -306,14 +313,14 @@ export function paneSyncSettings(
|
||||
});
|
||||
}
|
||||
|
||||
renderObsidianSetting(paneEl, "suppressNotifyHiddenFilesChange", {}).setClass("wizardHidden");
|
||||
renderObsidianSetting(paneEl, "syncInternalFilesBeforeReplication", {
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("suppressNotifyHiddenFilesChange", {});
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("syncInternalFilesBeforeReplication", {
|
||||
onUpdate: visibleOnly(() => this.isConfiguredAs("watchInternalFileChanges", true)),
|
||||
}).setClass("wizardHidden");
|
||||
});
|
||||
|
||||
renderObsidianSetting(paneEl, "syncInternalFilesInterval", {
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireNumeric("syncInternalFilesInterval", {
|
||||
clampMin: 10,
|
||||
acceptZero: true,
|
||||
}).setClass("wizardHidden");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { $msg } from "@lib/common/i18n";
|
||||
import {
|
||||
LEVEL_ADVANCED,
|
||||
LEVEL_EDGE_CASE,
|
||||
LEVEL_POWER_USER,
|
||||
type ConfigLevel,
|
||||
type SettingDefinition,
|
||||
} from "@lib/common/types";
|
||||
import { LEVEL_ADVANCED, LEVEL_EDGE_CASE, LEVEL_POWER_USER, type ConfigLevel } from "@lib/common/types";
|
||||
import type { AllSettingItemKey, AllSettings } from "./settingConstants";
|
||||
|
||||
export const combineOnUpdate = (func1: OnUpdateFunc, func2: OnUpdateFunc): OnUpdateFunc => {
|
||||
@@ -83,7 +77,6 @@ export type AutoWireOption = {
|
||||
invert?: boolean;
|
||||
onUpdate?: OnUpdateFunc;
|
||||
obsolete?: boolean;
|
||||
settingDefinition?: SettingDefinition<AllSettingItemKey>;
|
||||
};
|
||||
|
||||
export function findAttrFromParent(el: HTMLElement, attr: string): string {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { pickBucketSyncSettings, pickCouchDBSyncSettings, pickP2PSyncSettings } from "@lib/common/utils.ts";
|
||||
import {
|
||||
pickBucketSyncSettings,
|
||||
pickCouchDBSyncSettings,
|
||||
pickP2PSyncSettings,
|
||||
pickWebDAVSyncSettings,
|
||||
} from "@lib/common/utils.ts";
|
||||
import type { ObsidianLiveSyncSettings } from "@lib/common/types.ts";
|
||||
|
||||
// Keep the setting dialogue buffer aligned with the current core settings before persisting other dirty keys.
|
||||
@@ -13,5 +18,6 @@ export function syncActivatedRemoteSettings(
|
||||
...pickBucketSyncSettings(source),
|
||||
...pickCouchDBSyncSettings(source),
|
||||
...pickP2PSyncSettings(source),
|
||||
...pickWebDAVSyncSettings(source),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO } from "@lib/common/types";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_WEBDAV } from "@lib/common/types";
|
||||
import { syncActivatedRemoteSettings } from "./remoteConfigBuffer";
|
||||
|
||||
describe("syncActivatedRemoteSettings", () => {
|
||||
@@ -80,4 +80,25 @@ describe("syncActivatedRemoteSettings", () => {
|
||||
expect(target.couchDB_PASSWORD).toBe("current-pass");
|
||||
expect(target.couchDB_DBNAME).toBe("current-db");
|
||||
});
|
||||
|
||||
it("should copy active WebDAV connection URI into the editing buffer", () => {
|
||||
const target = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
activeConfigurationId: "old-remote",
|
||||
webDAVactiveConnectionURI: "",
|
||||
};
|
||||
const source = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_WEBDAV,
|
||||
activeConfigurationId: "remote-webdav",
|
||||
webDAVactiveConnectionURI: "sls+webdav://user:pass@example.com/dav?prefix=vault%2F",
|
||||
};
|
||||
|
||||
syncActivatedRemoteSettings(target, source);
|
||||
|
||||
expect(target.remoteType).toBe(REMOTE_WEBDAV);
|
||||
expect(target.activeConfigurationId).toBe("remote-webdav");
|
||||
expect(target.webDAVactiveConnectionURI).toBe("sls+webdav://user:pass@example.com/dav?prefix=vault%2F");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ConnectionStringParser } from "@lib/common/ConnectionString.ts";
|
||||
|
||||
export function getRemoteConfigurationDescription(uri: string): string {
|
||||
try {
|
||||
const parsed = ConnectionStringParser.parse(uri);
|
||||
if (parsed.type === "couchdb") {
|
||||
const url = new URL(parsed.settings.couchDB_URI);
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
}
|
||||
if (parsed.type === "s3") {
|
||||
return `${parsed.settings.endpoint.replace(/\/+$/g, "")}/${parsed.settings.bucket}`;
|
||||
}
|
||||
if (parsed.type === "webdav") {
|
||||
const url = new URL(parsed.settings.webDAVactiveConnectionURI.replace(/^sls\+webdav:/, "https:"));
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
}
|
||||
return `P2P ${parsed.settings.P2P_roomID || "Remote"}`;
|
||||
} catch {
|
||||
return uri.split("@").pop()?.split("?")[0] || "";
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,14 @@ import {
|
||||
type EncryptionSettings,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type P2PSyncSetting,
|
||||
type WebDAVSyncSetting,
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
REMOTE_WEBDAV,
|
||||
} from "@lib/common/types.ts";
|
||||
import { isObjectDifferent } from "@lib/common/utils.ts";
|
||||
import Intro from "./SetupWizard/dialogs/Intro.svelte";
|
||||
@@ -24,6 +26,7 @@ import SetupRemote from "./SetupWizard/dialogs/SetupRemote.svelte";
|
||||
import SetupRemoteCouchDB from "./SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
|
||||
import SetupRemoteBucket from "./SetupWizard/dialogs/SetupRemoteBucket.svelte";
|
||||
import SetupRemoteP2P from "./SetupWizard/dialogs/SetupRemoteP2P.svelte";
|
||||
import SetupRemoteWebDAV from "./SetupWizard/dialogs/SetupRemoteWebDAV.svelte";
|
||||
import SetupRemoteE2EE from "./SetupWizard/dialogs/SetupRemoteE2EE.svelte";
|
||||
import { decodeSettingsFromQRCodeData } from "@lib/API/processSetting.ts";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
@@ -38,6 +41,7 @@ import type {
|
||||
SetupRemoteE2EEResultType,
|
||||
SetupRemoteP2PResultType,
|
||||
SetupRemoteResultType,
|
||||
SetupRemoteWebDAVResultType,
|
||||
UseSetupURIResultType,
|
||||
} from "./SetupWizard/dialogs/setupDialogTypes.ts";
|
||||
|
||||
@@ -202,6 +206,33 @@ export class SetupManager extends AbstractModule {
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles manual setup for WebDAV-compatible storage.
|
||||
* @param userMode
|
||||
* @param currentSetting
|
||||
* @param activate Whether to activate WebDAV as remote type
|
||||
* @returns Promise that resolves to true if setup completed successfully, false otherwise
|
||||
*/
|
||||
async onWebDAVManualSetup(
|
||||
userMode: UserMode,
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
activate = true
|
||||
): Promise<boolean> {
|
||||
const webDAVConf = await this.dialogManager.openWithExplicitCancel<
|
||||
SetupRemoteWebDAVResultType,
|
||||
WebDAVSyncSetting
|
||||
>(SetupRemoteWebDAV, currentSetting);
|
||||
if (webDAVConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
}
|
||||
const newSetting = { ...currentSetting, ...webDAVConf } as ObsidianLiveSyncSettings;
|
||||
if (activate) {
|
||||
newSetting.remoteType = REMOTE_WEBDAV;
|
||||
}
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles manual setup for P2P
|
||||
* @param userMode
|
||||
@@ -303,6 +334,8 @@ export class SetupManager extends AbstractModule {
|
||||
return await this.onBucketManualSetup(userMode, currentSetting, true);
|
||||
} else if (method === "p2p") {
|
||||
return await this.onP2PManualSetup(userMode, currentSetting, true);
|
||||
} else if (method === "webdav") {
|
||||
return await this.onWebDAVManualSetup(userMode, currentSetting, true);
|
||||
} else if (method === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
if (userMode !== UserMode.Unknown) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
TYPE_COUCHDB,
|
||||
TYPE_BUCKET,
|
||||
TYPE_P2P,
|
||||
TYPE_WEBDAV,
|
||||
TYPE_CANCELLED,
|
||||
type SetupRemoteResultType,
|
||||
} from "./setupDialogTypes";
|
||||
@@ -26,12 +27,19 @@
|
||||
return "Continue to S3/MinIO/R2 setup";
|
||||
} else if (userType === TYPE_P2P) {
|
||||
return "Continue to Peer-to-Peer only setup";
|
||||
} else if (userType === TYPE_WEBDAV) {
|
||||
return "Continue to WebDAV setup";
|
||||
} else {
|
||||
return "Please select an option to proceed";
|
||||
}
|
||||
});
|
||||
const canProceed = $derived.by(() => {
|
||||
return userType === TYPE_COUCHDB || userType === TYPE_BUCKET || userType === TYPE_P2P;
|
||||
return (
|
||||
userType === TYPE_COUCHDB ||
|
||||
userType === TYPE_BUCKET ||
|
||||
userType === TYPE_P2P ||
|
||||
userType === TYPE_WEBDAV
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -46,6 +54,10 @@
|
||||
<Option selectedValue={TYPE_BUCKET} title="S3/MinIO/R2 Object Storage" bind:value={userType}>
|
||||
Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.
|
||||
</Option>
|
||||
<Option selectedValue={TYPE_WEBDAV} title="WebDAV (Experimental)" bind:value={userType}>
|
||||
Experimental synchronisation utilising journal files stored in a WebDAV collection. This is a proof of
|
||||
concept for pluggable Journal storage backends.
|
||||
</Option>
|
||||
<Option selectedValue={TYPE_P2P} title="Peer-to-Peer only" bind:value={userType}>
|
||||
This feature enables direct synchronisation between devices. No server is required, but both devices must be
|
||||
online at the same time for synchronisation to occur, and some features may be limited. Internet connection
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
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 {
|
||||
DEFAULT_SETTINGS,
|
||||
PREFERRED_JOURNAL_SYNC,
|
||||
RemoteTypes,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type WebDAVSyncSetting,
|
||||
} from "@lib/common/types";
|
||||
import { copyTo, pickWebDAVSyncSettings } from "@lib/common/utils";
|
||||
import { getDialogContext, type GuestDialogProps } from "@lib/UI/svelteDialog";
|
||||
import { onMount } from "svelte";
|
||||
import { TYPE_CANCELLED, type SetupRemoteWebDAVResultType } from "./setupDialogTypes";
|
||||
|
||||
const default_setting = pickWebDAVSyncSettings(DEFAULT_SETTINGS);
|
||||
|
||||
let syncSetting = $state<WebDAVSyncSetting>({ ...default_setting });
|
||||
|
||||
type Props = GuestDialogProps<SetupRemoteWebDAVResultType, WebDAVSyncSetting>;
|
||||
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
if (getInitialData) {
|
||||
const initialData = getInitialData();
|
||||
if (initialData) {
|
||||
copyTo(initialData, syncSetting);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let error = $state("");
|
||||
let processing = $state(false);
|
||||
const context = getDialogContext();
|
||||
|
||||
function parseURI(): URL | false {
|
||||
const match = syncSetting.webDAVactiveConnectionURI.trim().match(/^sls\+webdav:(.*)$/);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return new URL(`https:${match[1]}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const parsedURI = $derived.by(() => parseURI());
|
||||
const canProceed = $derived.by(() => parsedURI !== false);
|
||||
const isInsecure = $derived.by(() => parsedURI !== false && parsedURI.searchParams.get("insecure") === "true");
|
||||
const usesInternalAPI = $derived.by(
|
||||
() => parsedURI !== false && parsedURI.searchParams.get("useProxy") === "true"
|
||||
);
|
||||
|
||||
function generateSetting() {
|
||||
const trialSettings: WebDAVSyncSetting = {
|
||||
...syncSetting,
|
||||
webDAVactiveConnectionURI: syncSetting.webDAVactiveConnectionURI.trim(),
|
||||
};
|
||||
const trialRemoteSetting: ObsidianLiveSyncSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...PREFERRED_JOURNAL_SYNC,
|
||||
remoteType: RemoteTypes.REMOTE_WEBDAV,
|
||||
...trialSettings,
|
||||
};
|
||||
return trialRemoteSetting;
|
||||
}
|
||||
|
||||
async function checkConnection() {
|
||||
try {
|
||||
processing = true;
|
||||
const trialRemoteSetting = generateSetting();
|
||||
const replicator = await context.services.replicator.getNewReplicator(trialRemoteSetting);
|
||||
if (!replicator) {
|
||||
return "Failed to create replicator instance.";
|
||||
}
|
||||
try {
|
||||
const result = await replicator.tryConnectRemote(trialRemoteSetting, false);
|
||||
if (result) {
|
||||
return "";
|
||||
}
|
||||
return "Failed to connect to the server. Please check your settings.";
|
||||
} catch (e) {
|
||||
return `Failed to connect to the server: ${e}`;
|
||||
}
|
||||
} finally {
|
||||
processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAndCommit() {
|
||||
error = "";
|
||||
try {
|
||||
error = (await checkConnection()) || "";
|
||||
if (!error) {
|
||||
setResult(pickWebDAVSyncSettings(generateSetting()));
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
error = `Error during connection test: ${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
function commit() {
|
||||
setResult(pickWebDAVSyncSettings(generateSetting()));
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
setResult(TYPE_CANCELLED);
|
||||
}
|
||||
</script>
|
||||
|
||||
<DialogHeader title="WebDAV Configuration" />
|
||||
<Guidance>
|
||||
Please enter the WebDAV connection URI. This experimental setup stores Journal synchronisation files in a WebDAV
|
||||
collection.
|
||||
</Guidance>
|
||||
|
||||
<InputRow label="Connection URI">
|
||||
<input
|
||||
type="text"
|
||||
name="webdav-connection-uri"
|
||||
placeholder="sls+webdav://user:password@example.com/dav?prefix=vault%2F"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
bind:value={syncSetting.webDAVactiveConnectionURI}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
Use `sls+webdav://user:password@host/path`. Optional query parameters include `prefix`, `useProxy=true`, and
|
||||
`insecure=true` for local HTTP testing.
|
||||
</InfoNote>
|
||||
<InfoNote warning visible={isInsecure}>Secure HTTPS connections are required on Obsidian Mobile.</InfoNote>
|
||||
<InfoNote visible={usesInternalAPI}>
|
||||
This connection uses Obsidian's internal API. It can help when direct WebDAV requests are blocked by CORS.
|
||||
</InfoNote>
|
||||
<InfoNote error visible={syncSetting.webDAVactiveConnectionURI.trim() !== "" && !canProceed}>
|
||||
The connection URI must start with `sls+webdav://`.
|
||||
</InfoNote>
|
||||
|
||||
<InfoNote error visible={error !== ""}>
|
||||
{error}
|
||||
</InfoNote>
|
||||
|
||||
{#if processing}
|
||||
Checking connection... Please wait.
|
||||
{:else}
|
||||
<UserDecisions>
|
||||
<Decision title="Test Settings and Continue" important disabled={!canProceed} commit={() => checkAndCommit()} />
|
||||
<Decision title="Continue anyway" disabled={!canProceed} commit={() => commit()} />
|
||||
<Decision title="Cancel" commit={() => cancel()} />
|
||||
</UserDecisions>
|
||||
{/if}
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
EncryptionSettings,
|
||||
ObsidianLiveSyncSettings,
|
||||
P2PConnectionInfo,
|
||||
WebDAVSyncSetting,
|
||||
} from "@lib/common/models/setting.type";
|
||||
|
||||
export const TYPE_IDENTICAL = "identical";
|
||||
@@ -40,6 +41,7 @@ export const TYPE_CLOSE = "close";
|
||||
export const TYPE_COUCHDB = "couchdb";
|
||||
export const TYPE_BUCKET = "bucket";
|
||||
export const TYPE_P2P = "p2p";
|
||||
export const TYPE_WEBDAV = "webdav";
|
||||
|
||||
export type ResultTypeVault =
|
||||
| typeof TYPE_IDENTICAL
|
||||
@@ -93,7 +95,12 @@ export type SelectMethodExistingResultType =
|
||||
| typeof TYPE_CONFIGURE_MANUALLY
|
||||
| typeof TYPE_CANCELLED;
|
||||
|
||||
export type SetupRemoteResultType = typeof TYPE_COUCHDB | typeof TYPE_BUCKET | typeof TYPE_P2P | typeof TYPE_CANCELLED;
|
||||
export type SetupRemoteResultType =
|
||||
| typeof TYPE_COUCHDB
|
||||
| typeof TYPE_BUCKET
|
||||
| typeof TYPE_P2P
|
||||
| typeof TYPE_WEBDAV
|
||||
| typeof TYPE_CANCELLED;
|
||||
|
||||
export type UseSetupURIResultType = typeof TYPE_CANCELLED | ObsidianLiveSyncSettings;
|
||||
|
||||
@@ -105,4 +112,6 @@ export type SetupRemoteCouchDBResultType = typeof TYPE_CANCELLED | CouchDBConnec
|
||||
|
||||
export type SetupRemoteP2PResultType = typeof TYPE_CANCELLED | P2PConnectionInfo;
|
||||
|
||||
export type SetupRemoteWebDAVResultType = typeof TYPE_CANCELLED | WebDAVSyncSetting;
|
||||
|
||||
export type ScanQRCodeResultType = typeof TYPE_CLOSE;
|
||||
|
||||
@@ -147,14 +147,12 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
||||
: req instanceof Request && typeof req.method === "string"
|
||||
? req.method
|
||||
: "GET";
|
||||
if (typeof req !== "string") {
|
||||
if (opts?.body) {
|
||||
body = typeof opts.body === "string" ? opts.body : await new Response(opts.body).arrayBuffer();
|
||||
} else if (req.body) {
|
||||
body = await new Response(req.body).arrayBuffer();
|
||||
}
|
||||
if (opts?.body) {
|
||||
body = typeof opts.body === "string" ? opts.body : await new Response(opts.body).arrayBuffer();
|
||||
} else if (typeof req !== "string" && req.body) {
|
||||
body = await new Response(req.body).arrayBuffer();
|
||||
} else {
|
||||
body = opts?.body as string;
|
||||
body = undefined;
|
||||
}
|
||||
const reqHeaders = new Headers(req instanceof Request ? req.headers : {});
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const requestUrlMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/deps", () => {
|
||||
return {
|
||||
requestUrl: requestUrlMock,
|
||||
Notice: vi.fn(),
|
||||
Platform: {},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/deps.ts", () => {
|
||||
return {
|
||||
requestUrl: requestUrlMock,
|
||||
Notice: vi.fn(),
|
||||
Platform: {},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./ObsidianConfirm", () => ({
|
||||
ObsidianConfirm: class {},
|
||||
}));
|
||||
|
||||
import { ObsidianAPIService } from "./ObsidianAPIService.ts";
|
||||
import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext.ts";
|
||||
|
||||
describe("ObsidianAPIService.nativeFetch", () => {
|
||||
it("should pass binary string-request bodies to requestUrl as ArrayBuffer", async () => {
|
||||
requestUrlMock.mockResolvedValueOnce({
|
||||
arrayBuffer: new TextEncoder().encode("ok").buffer,
|
||||
headers: {},
|
||||
status: 207,
|
||||
});
|
||||
const service = new ObsidianAPIService({} as ObsidianServiceContext);
|
||||
const body = new TextEncoder().encode("payload");
|
||||
|
||||
const response = await service.nativeFetch("https://webdav.example.com/file", {
|
||||
method: "PUT",
|
||||
body: body as unknown as BodyInit,
|
||||
headers: {
|
||||
Depth: "1",
|
||||
Authorization: "Basic dXNlcjpwYXNz",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(207);
|
||||
expect(requestUrlMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://webdav.example.com/file",
|
||||
method: "PUT",
|
||||
body: expect.any(ArrayBuffer),
|
||||
headers: expect.objectContaining({
|
||||
Depth: "1",
|
||||
Authorization: "Basic dXNlcjpwYXNz",
|
||||
}),
|
||||
throw: false,
|
||||
})
|
||||
);
|
||||
const requestBody = requestUrlMock.mock.calls[0][0].body as ArrayBuffer;
|
||||
expect(new TextDecoder().decode(requestBody)).toBe("payload");
|
||||
});
|
||||
});
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
docker inspect webdav-test >/dev/null
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
webdavUsername=${webdavUsername:-webdav}
|
||||
webdavPassword=${webdavPassword:-webdav}
|
||||
webdavPort=${webdavPort:-8088}
|
||||
|
||||
docker run -d \
|
||||
--name webdav-test \
|
||||
-p "$webdavPort:8080" \
|
||||
rclone/rclone serve webdav /data \
|
||||
--addr :8080 \
|
||||
--baseurl /dav \
|
||||
--user "$webdavUsername" \
|
||||
--pass "$webdavPassword"
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
docker stop webdav-test
|
||||
docker rm webdav-test
|
||||
@@ -3,6 +3,13 @@ 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
|
||||
|
||||
### Experimental
|
||||
|
||||
- Added experimental WebDAV support for Journal synchronisation storage, including setup dialogue integration. This is currently a proof of concept for injecting Journal storage backends through the replication layer, and should be treated as an implementation experiment rather than a fully supported setup flow.
|
||||
- Hardened the WebDAV Journal storage proof of concept by centralising Journal storage adapter selection, improving connection checks, masking remote configuration descriptions, and verifying the Docker WebDAV integration path.
|
||||
|
||||
## 0.25.79
|
||||
|
||||
29th June, 2026
|
||||
|
||||
Reference in New Issue
Block a user