Compare commits

..

2 Commits

Author SHA1 Message Date
vorotamoroz 2090d42631 Add Obsidian setting definition renderer 2026-07-03 11:24:19 +00:00
vorotamoroz c9ff34842f Document setting definition repository design 2026-07-03 10:27:50 +00:00
32 changed files with 675 additions and 758 deletions
-82
View File
@@ -1,82 +0,0 @@
name: Finalise Release Tags
on:
workflow_dispatch:
inputs:
version:
description: Release version, for example 0.25.81
required: true
type: string
release_branch:
description: Release PR branch. Defaults to the version with dots replaced by underscores.
required: false
type: string
jobs:
finalise:
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
steps:
- name: Resolve release branch
id: branch
env:
VERSION: ${{ inputs.version }}
RELEASE_BRANCH_INPUT: ${{ inputs.release_branch }}
run: |
set -euo pipefail
BRANCH="${RELEASE_BRANCH_INPUT}"
if [[ -z "${BRANCH}" ]]; then
BRANCH="${VERSION//./_}"
fi
echo "name=${BRANCH}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
with:
ref: ${{ steps.branch.outputs.name }}
fetch-depth: 0
submodules: recursive
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: "24.x"
- name: Validate release head
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
node utils/release-notes.mjs validate "${VERSION}"
git fetch --tags --force
if git rev-parse --verify --quiet "refs/tags/${VERSION}" >/dev/null; then
echo "Tag already exists: ${VERSION}" >&2
exit 1
fi
if git rev-parse --verify --quiet "refs/tags/${VERSION}-cli" >/dev/null; then
echo "Tag already exists: ${VERSION}-cli" >&2
exit 1
fi
- name: Create and push release tags
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
git tag "${VERSION}"
git tag "${VERSION}-cli"
git push origin "${VERSION}" "${VERSION}-cli"
- name: Summarise next steps
env:
VERSION: ${{ inputs.version }}
run: |
{
echo "Created tags \`${VERSION}\` and \`${VERSION}-cli\`."
echo ""
echo "The plug-in release workflow is triggered by the \`${VERSION}\` tag and creates a draft release by default."
echo "The CLI Docker workflow is triggered by the \`${VERSION}-cli\` tag and publishes the version, major-minor, latest, and SHA-qualified image tags."
echo ""
echo "To create a pre-release instead of the default draft flow, run \`Release Obsidian Plugin\` manually with \`tag=${VERSION}\`, \`draft=false\`, and \`prerelease=true\`."
} >> "$GITHUB_STEP_SUMMARY"
-102
View File
@@ -1,102 +0,0 @@
name: Prepare Release PR
on:
workflow_dispatch:
inputs:
version:
description: Release version, for example 0.25.81
required: true
type: string
base_branch:
description: Base branch for the release PR
required: false
type: string
default: main
release_branch:
description: Release branch name. Defaults to the version with dots replaced by underscores.
required: false
type: string
allow_empty_updates:
description: Allow an empty Unreleased section
required: false
type: boolean
default: false
jobs:
prepare:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.base_branch }}
fetch-depth: 0
submodules: recursive
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: "24.x"
cache: npm
- name: Install dependencies
run: npm ci
- name: Prepare release changes
id: prepare
env:
VERSION: ${{ inputs.version }}
RELEASE_BRANCH_INPUT: ${{ inputs.release_branch }}
ALLOW_EMPTY_UPDATES: ${{ inputs.allow_empty_updates }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
BRANCH="${RELEASE_BRANCH_INPUT}"
if [[ -z "${BRANCH}" ]]; then
BRANCH="${VERSION//./_}"
fi
if git ls-remote --exit-code --heads origin "${BRANCH}" >/dev/null 2>&1; then
echo "Release branch already exists: ${BRANCH}" >&2
exit 1
fi
git switch -c "${BRANCH}"
npm version "${VERSION}" --no-git-tag-version
node utils/release-notes.mjs prepare "${VERSION}"
npm run pretty:json
git add package.json package-lock.json manifest.json versions.json updates.md src/apps/cli/package.json src/apps/webpeer/package.json src/apps/webapp/package.json
git diff --cached --check
git commit -m "Releasing ${VERSION}"
git push --set-upstream origin "${BRANCH}"
echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"
- name: Create draft release PR
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
BASE_BRANCH: ${{ inputs.base_branch }}
RELEASE_BRANCH: ${{ steps.prepare.outputs.branch }}
run: |
cat > /tmp/release-pr-body.md <<EOF
## Release checklist
- [ ] Review and polish \`updates.md\`
- [ ] Confirm \`manifest.json\`, \`versions.json\`, and workspace package versions
- [ ] Confirm CI has passed
- [ ] Run the finalise release workflow after the PR head is fixed
- [ ] Merge this PR after the draft or pre-release has been created
EOF
gh pr create \
--base "${BASE_BRANCH}" \
--head "${RELEASE_BRANCH}" \
--draft \
--title "Releasing ${VERSION}" \
--body-file /tmp/release-pr-body.md
+2 -31
View File
@@ -6,26 +6,10 @@ on:
- '*' # Push events to matching any tag format, i.e. 1.0, 20.15.10
- '!*-cli' # Exclude command-line interface tags
workflow_dispatch:
inputs:
tag:
description: Release tag to build
required: true
type: string
draft:
description: Create the GitHub Release as a draft
required: false
type: boolean
default: true
prerelease:
description: Mark the GitHub Release as a pre-release
required: false
type: boolean
default: false
jobs:
build:
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
id-token: write
@@ -35,7 +19,6 @@ jobs:
with:
fetch-depth: 0 # otherwise, you will failed to push refs to dest repo
submodules: recursive
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
- name: Use Node.js
uses: actions/setup-node@v4
with:
@@ -44,18 +27,7 @@ jobs:
- name: Get Version
id: version
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
TAG="${{ inputs.tag }}"
DRAFT="${{ inputs.draft }}"
PRERELEASE="${{ inputs.prerelease }}"
else
TAG="${GITHUB_REF_NAME}"
DRAFT="true"
PRERELEASE="false"
fi
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "draft=${DRAFT}" >> $GITHUB_OUTPUT
echo "prerelease=${PRERELEASE}" >> $GITHUB_OUTPUT
echo "tag=$(git describe --abbrev=0 --tags)" >> $GITHUB_OUTPUT
# Build the plugin
- name: Build
id: build
@@ -86,5 +58,4 @@ jobs:
styles.css
name: ${{ steps.version.outputs.tag }}
tag_name: ${{ steps.version.outputs.tag }}
draft: ${{ steps.version.outputs.draft }}
prerelease: ${{ steps.version.outputs.prerelease }}
draft: true
-55
View File
@@ -148,20 +148,6 @@ Hence, the new feature should be implemented as follows:
- **Service Hub** (`src/modules/services/`): Central service registry using dependency injection
- **Common Library** (`src/lib/`): Platform-independent sync logic, shared with other tools
### Conflict Merge Policy
Markdown conflict auto-merge should behave like a conservative three-way merge. The guiding rule is to merge changes when they touch non-overlapping regions, and to keep a manual conflict when the edits overlap semantically.
When in doubt, prefer the safer outcome: preserve data, keep the conflict visible, and ask the user rather than silently discarding content or choosing one side.
- If one side deletes a line and the other side leaves that same line unchanged, treat it as a safe deletion. The deleted line must not be reintroduced into the merged result.
- If one side inserts new content in a different region while the other side deletes an unchanged old region, preserve the insertion and the deletion.
- If one side deletes a line and the other side modifies that same line, keep the conflict for user resolution.
- If both sides insert different content at the same position, keep both insertions in a deterministic order unless the surrounding deletion context indicates that they are competing replacements.
- Avoid resolving conflicts by simply choosing the newest revision unless the user has explicitly selected that behaviour.
This policy is intentionally aligned with the conflict checkboxes and compatibility settings: automatic merge should remove avoidable prompts, but it must not silently choose between overlapping user intentions.
### File Structure Conventions
- **Platform-specific code**: Use `.platform.ts` suffix (replaced with `.obsidian.ts` in production builds via esbuild)
@@ -252,47 +238,6 @@ export class ModuleExample extends AbstractObsidianModule {
In short, the situation remains unchanged for me, but it means you all become a little safer. Thank you for your understanding!
## Release Notes
- Keep the top section of `updates.md` as `## Unreleased` during normal development.
- When opening a feature or fix PR, update `## Unreleased` in the same PR if the change is user-facing.
- Add only user-facing changes that help users understand what they gain, what has changed, or what they may need to do after updating.
- Avoid listing purely internal refactors, maintenance chores, generated-file changes, and dependency updates unless they affect users; group and label them when they are included.
- When preparing a release, replace `## Unreleased` with the target version heading (for example, `## 0.25.81`) and add a fresh empty `## Unreleased` section above it for the next cycle.
- Review and polish the released section in the release PR before tagging, because the content is embedded into the plug-in and may be reused as the GitHub Release notes.
## Release Workflow
This workflow is for maintainers. Contributors should update `## Unreleased` for user-facing feature or fix PRs, but do not need to run the release workflows.
The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `release` GitHub Environment. Configure Environment protection in the repository settings so tag creation and release publication require maintainer approval.
- Run the `Prepare Release PR` workflow with the target version. It creates the release branch, updates versions, moves the `## Unreleased` notes to the target version, commits the release preparation, pushes the branch, and opens a draft release PR.
- Do not tag the release branch when the PR is first created. Polish the release PR first, especially `updates.md`.
- Once the release PR head is fixed, run the `Finalise Release Tags` workflow. It validates the release branch and pushes both the plug-in tag (for example, `0.25.81`) and the CLI tag (for example, `0.25.81-cli`) to the same commit.
- The plug-in tag triggers the release workflow and creates a draft GitHub Release by default. The CLI tag triggers the Docker workflow and publishes the fixed version tag, the major-minor moving tag (for example, `0.25-cli`), `latest`, and the SHA-qualified tag.
- If a pre-release is needed, run the `Release Obsidian Plugin` workflow manually with the target tag, `draft=false`, and `prerelease=true`.
### Release Cheat Sheet
1. Before starting, add user-facing notes under `## Unreleased` in `updates.md`.
2. Run `Prepare Release PR` from GitHub Actions.
- `version`: the target version, for example `0.25.81`.
- `base_branch`: normally `main`.
- `release_branch`: leave blank to use the default branch name, for example `0_25_81`.
- `allow_empty_updates`: leave disabled unless the release intentionally has no user-facing notes.
3. Review the generated draft PR.
- Polish `updates.md`.
- Confirm `package.json`, `manifest.json`, `versions.json`, and workspace package versions.
- Confirm that `manifest.json` has the intended `minAppVersion`.
- Wait for the necessary CI checks.
4. When the PR head is fixed, run `Finalise Release Tags`.
- `version`: the same target version.
- `release_branch`: leave blank unless the release branch used a custom name.
5. Check the generated draft GitHub Release for the plug-in tag.
6. Check the CLI Docker workflow started from the `*-cli` tag.
7. Publish the draft GitHub Release when ready, then merge the release PR into `main`.
8. If the release should be a pre-release instead of a draft release, run `Release Obsidian Plugin` manually with the target `tag`, `draft=false`, and `prerelease=true`.
## Contribution Guidelines
- Follow existing code style and conventions
@@ -0,0 +1,360 @@
# 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.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "0.25.80",
"version": "0.25.79",
"minAppVersion": "1.7.2",
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"author": "vorotamoroz",
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "obsidian-livesync",
"version": "0.25.80",
"version": "0.25.79",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-livesync",
"version": "0.25.80",
"version": "0.25.79",
"license": "MIT",
"workspaces": [
"src/apps/cli",
@@ -16210,7 +16210,7 @@
},
"src/apps/cli": {
"name": "self-hosted-livesync-cli",
"version": "0.25.80-cli",
"version": "0.25.79-cli",
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
@@ -16236,7 +16236,7 @@
},
"src/apps/webapp": {
"name": "livesync-webapp",
"version": "0.25.80-webapp",
"version": "0.25.79-webapp",
"dependencies": {
"octagonal-wheels": "^0.1.47"
},
@@ -16251,7 +16251,7 @@
}
},
"src/apps/webpeer": {
"version": "0.25.80-webpeer",
"version": "0.25.79-webpeer",
"dependencies": {
"octagonal-wheels": "^0.1.47"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
"version": "0.25.80",
"version": "0.25.79",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"main": "main.js",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "0.25.80-cli",
"version": "0.25.79-cli",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "0.25.80-webapp",
"version": "0.25.79-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "0.25.80-webpeer",
"version": "0.25.79-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
@@ -51,7 +51,6 @@ import { EVENT_SETTING_SAVED, eventHub } from "@/common/events.ts";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import type { LiveSyncCore } from "@/main.ts";
import { tryGetFilePath } from "@lib/common/utils.doc.ts";
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
type SyncDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce";
declare global {
@@ -1833,35 +1832,43 @@ ${messageFetch}${messageOverwrite}${messageMerge}
}
async configureHiddenFileSync(mode: keyof OPTIONAL_SYNC_FEATURES) {
const result = await configureHiddenFileSyncMode(mode, {
disable: async () => {
// await this.core.$allSuspendExtraSync();
await this.core.services.setting.applyPartial(
{
syncInternalFiles: false,
},
true
);
// this.core.settings.syncInternalFiles = false;
// await this.core.saveSettings();
},
enable: async () => {
this._log("Gathering files for enabling Hidden File Sync", LOG_LEVEL_NOTICE);
await this.core.services.setting.applyPartial(
{
useAdvancedMode: true,
syncInternalFiles: true,
},
true
);
},
initialise: async (direction) => {
await this.initialiseInternalFileSync(direction, true);
},
});
if (result == "ignored" || result == "disabled") {
if (
mode != "FETCH" &&
mode != "OVERWRITE" &&
mode != "MERGE" &&
mode != "DISABLE" &&
mode != "DISABLE_HIDDEN"
) {
return;
}
if (mode == "DISABLE" || mode == "DISABLE_HIDDEN") {
// await this.core.$allSuspendExtraSync();
await this.core.services.setting.applyPartial(
{
syncInternalFiles: false,
},
true
);
// this.core.settings.syncInternalFiles = false;
// await this.core.saveSettings();
return;
}
this._log("Gathering files for enabling Hidden File Sync", LOG_LEVEL_NOTICE);
if (mode == "FETCH") {
await this.initialiseInternalFileSync("pullForce", true);
} else if (mode == "OVERWRITE") {
await this.initialiseInternalFileSync("pushForce", true);
} else if (mode == "MERGE") {
await this.initialiseInternalFileSync("safe", true);
}
await this.core.services.setting.applyPartial(
{
useAdvancedMode: true,
syncInternalFiles: true,
},
true
);
// this.plugin.settings.useAdvancedMode = true;
// this.plugin.settings.syncInternalFiles = true;
@@ -1,45 +0,0 @@
type HiddenFileSyncMode = "FETCH" | "OVERWRITE" | "MERGE" | "DISABLE" | "DISABLE_HIDDEN";
type HiddenFileSyncDirection = "pullForce" | "pushForce" | "safe";
type ConfigureHiddenFileSyncHandlers = {
disable: () => Promise<void>;
enable: () => Promise<void>;
initialise: (direction: HiddenFileSyncDirection) => Promise<void>;
};
export type ConfigureHiddenFileSyncResult = "ignored" | "disabled" | "enabled";
function getInitialiseDirection(mode: keyof OPTIONAL_SYNC_FEATURES): HiddenFileSyncDirection | false {
if (mode == "FETCH") return "pullForce";
if (mode == "OVERWRITE") return "pushForce";
if (mode == "MERGE") return "safe";
return false;
}
function isDisableMode(mode: keyof OPTIONAL_SYNC_FEATURES): boolean {
return mode == "DISABLE" || mode == "DISABLE_HIDDEN";
}
function isHiddenFileSyncMode(mode: keyof OPTIONAL_SYNC_FEATURES): mode is HiddenFileSyncMode {
return mode == "FETCH" || mode == "OVERWRITE" || mode == "MERGE" || isDisableMode(mode);
}
export async function configureHiddenFileSyncMode(
mode: keyof OPTIONAL_SYNC_FEATURES,
handlers: ConfigureHiddenFileSyncHandlers
): Promise<ConfigureHiddenFileSyncResult> {
if (!isHiddenFileSyncMode(mode)) {
return "ignored";
}
if (isDisableMode(mode)) {
await handlers.disable();
return "disabled";
}
const direction = getInitialiseDirection(mode);
if (direction === false) {
return "ignored";
}
await handlers.enable();
await handlers.initialise(direction);
return "enabled";
}
@@ -1,47 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
describe("configureHiddenFileSyncMode", () => {
it.each([
["FETCH", "pullForce"],
["OVERWRITE", "pushForce"],
["MERGE", "safe"],
] as const)("enables hidden file sync before initialising %s", async (mode, direction) => {
const calls: string[] = [];
const result = await configureHiddenFileSyncMode(mode, {
disable: vi.fn(async () => {
calls.push("disable");
}),
enable: vi.fn(async () => {
calls.push("enable");
}),
initialise: vi.fn(async (actualDirection) => {
calls.push(`init:${actualDirection}`);
}),
});
expect(result).toBe("enabled");
expect(calls).toEqual(["enable", `init:${direction}`]);
});
it.each(["DISABLE", "DISABLE_HIDDEN"] as const)("disables hidden file sync immediately for %s", async (mode) => {
const calls: string[] = [];
const result = await configureHiddenFileSyncMode(mode, {
disable: vi.fn(async () => {
calls.push("disable");
}),
enable: vi.fn(async () => {
calls.push("enable");
}),
initialise: vi.fn(async (direction) => {
calls.push(`init:${direction}`);
}),
});
expect(result).toBe("disabled");
expect(calls).toEqual(["disable"]);
});
});
+1 -1
Submodule src/lib updated: a0efb7274e...c7c5fe21be
@@ -8,7 +8,13 @@ import {
type ValueComponent,
} from "@/deps.ts";
import { unique } from "octagonal-wheels/collection";
import { LEVEL_ADVANCED, LEVEL_POWER_USER, statusDisplay, type ConfigurationItem } from "@lib/common/types.ts";
import {
LEVEL_ADVANCED,
LEVEL_POWER_USER,
statusDisplay,
type ConfigurationItem,
type SettingDefinition,
} from "@lib/common/types.ts";
import { type ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import {
type AllSettingItemKey,
@@ -18,9 +24,21 @@ import {
type AllNumericItemKey,
type AllBooleanItemKey,
} from "./settingConstants.ts";
import { $msg } from "@lib/common/i18n.ts";
import { $msg, $t } 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;
@@ -56,7 +74,7 @@ export class LiveSyncSetting extends Setting {
return this;
}
autoWireSetting(key: AllSettingItemKey, opt?: AutoWireOption) {
const conf = getConfig(key);
const conf = opt?.settingDefinition ? configurationFromDefinition(opt.settingDefinition) : getConfig(key);
if (!conf) {
// throw new Error($msg("liveSyncSetting.errorNoSuchSettingItem", { key }));
return;
@@ -0,0 +1,76 @@
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,41 +1,37 @@
import { ChunkAlgorithmNames } from "@lib/common/types.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";
export function paneAdvanced(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
void addPanel(paneEl, "Memory cache").then((paneEl) => {
new Setting(paneEl).autoWireNumeric("hashCacheMaxCount", { clampMin: 10 });
// new Setting(paneEl).autoWireNumeric("hashCacheMaxAmount", { clampMin: 1 });
renderObsidianSetting(paneEl, "hashCacheMaxCount", { clampMin: 10 });
// renderObsidianSetting(paneEl, "hashCacheMaxAmount", { clampMin: 1 });
});
void addPanel(paneEl, "Local Database Tweak").then((paneEl) => {
paneEl.addClass("wizardHidden");
const items = ChunkAlgorithmNames;
new Setting(paneEl).autoWireDropDown("chunkSplitterVersion", {
renderObsidianSetting(paneEl, "chunkSplitterVersion", {
options: items,
});
new Setting(paneEl).autoWireNumeric("customChunkSize", { clampMin: 0, acceptZero: true });
renderObsidianSetting(paneEl, "customChunkSize", { clampMin: 0, acceptZero: true });
});
void addPanel(paneEl, "Transfer Tweak").then((paneEl) => {
new Setting(paneEl)
.setClass("wizardHidden")
.autoWireToggle("readChunksOnline", { onUpdate: this.onlyOnCouchDB });
new Setting(paneEl)
.setClass("wizardHidden")
.autoWireToggle("useOnlyLocalChunk", { onUpdate: this.onlyOnCouchDB });
renderObsidianSetting(paneEl, "readChunksOnline", { onUpdate: this.onlyOnCouchDB }).setClass("wizardHidden");
renderObsidianSetting(paneEl, "useOnlyLocalChunk", { onUpdate: this.onlyOnCouchDB }).setClass("wizardHidden");
new Setting(paneEl).setClass("wizardHidden").autoWireNumeric("concurrencyOfReadChunksOnline", {
renderObsidianSetting(paneEl, "concurrencyOfReadChunksOnline", {
clampMin: 10,
onUpdate: this.onlyOnCouchDB,
});
}).setClass("wizardHidden");
new Setting(paneEl).setClass("wizardHidden").autoWireNumeric("minimumIntervalOfReadChunksOnline", {
renderObsidianSetting(paneEl, "minimumIntervalOfReadChunksOnline", {
clampMin: 10,
onUpdate: this.onlyOnCouchDB,
});
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("autoAcceptCompatibleTweak");
}).setClass("wizardHidden");
renderObsidianSetting(paneEl, "autoAcceptCompatibleTweak").setClass("wizardHidden");
// new Setting(paneEl)
// .setClass("wizardHidden")
// .autoWireToggle("sendChunksBulk", { onUpdate: onlyOnCouchDB })
@@ -1,6 +1,7 @@
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(
@@ -35,27 +36,27 @@ export function paneCustomisationSync(
visibleOnly(() => this.isConfiguredAs("usePluginSync", true))
);
new Setting(paneEl).autoWireText("deviceAndVaultName", {
renderObsidianSetting(paneEl, "deviceAndVaultName", {
placeHolder: "desktop",
onUpdate: enableOnlyOnPluginSyncIsNotEnabled,
});
new Setting(paneEl).autoWireToggle("usePluginSyncV2");
renderObsidianSetting(paneEl, "usePluginSyncV2");
new Setting(paneEl).autoWireToggle("usePluginSync", {
renderObsidianSetting(paneEl, "usePluginSync", {
onUpdate: enableOnly(() => !this.isConfiguredAs("deviceAndVaultName", "")),
});
new Setting(paneEl).autoWireToggle("autoSweepPlugins", {
renderObsidianSetting(paneEl, "autoSweepPlugins", {
onUpdate: visibleOnlyOnPluginSyncEnabled,
});
new Setting(paneEl).autoWireToggle("autoSweepPluginsPeriodic", {
renderObsidianSetting(paneEl, "autoSweepPluginsPeriodic", {
onUpdate: visibleOnly(
() => this.isConfiguredAs("usePluginSync", true) && this.isConfiguredAs("autoSweepPlugins", true)
),
});
new Setting(paneEl).autoWireToggle("notifyPluginOrSettingUpdated", {
renderObsidianSetting(paneEl, "notifyPluginOrSettingUpdated", {
onUpdate: visibleOnlyOnPluginSyncEnabled,
});
@@ -1,6 +1,7 @@
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";
@@ -16,20 +17,20 @@ export function paneGeneral(
// ["", $msg("obsidianLiveSyncSettingTab.defaultLanguage")],
...SUPPORTED_I18N_LANGS.map((e) => [e, $t(`lang-${e}`)]),
]) as Record<I18N_LANGS, string>;
new Setting(paneEl).autoWireDropDown("displayLanguage", {
renderObsidianSetting(paneEl, "displayLanguage", {
options: languages,
});
this.addOnSaved("displayLanguage", () => this.display());
new Setting(paneEl).autoWireToggle("showStatusOnEditor");
renderObsidianSetting(paneEl, "showStatusOnEditor");
this.addOnSaved("showStatusOnEditor", () => {
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
});
new Setting(paneEl).autoWireToggle("showOnlyIconsOnEditor", {
renderObsidianSetting(paneEl, "showOnlyIconsOnEditor", {
onUpdate: visibleOnly(() => this.isConfiguredAs("showStatusOnEditor", true)),
});
new Setting(paneEl).autoWireToggle("showStatusOnStatusbar");
new Setting(paneEl).autoWireToggle("hideFileWarningNotice");
new Setting(paneEl).autoWireDropDown("networkWarningStyle", {
renderObsidianSetting(paneEl, "showStatusOnStatusbar");
renderObsidianSetting(paneEl, "hideFileWarningNotice");
renderObsidianSetting(paneEl, "networkWarningStyle", {
options: {
[NetworkWarningStyles.BANNER]: "Show full banner",
[NetworkWarningStyles.ICON]: "Show icon only",
@@ -43,9 +44,9 @@ export function paneGeneral(
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleLogging")).then((paneEl) => {
paneEl.addClass("wizardHidden");
new Setting(paneEl).autoWireToggle("lessInformationInLog");
renderObsidianSetting(paneEl, "lessInformationInLog");
new Setting(paneEl).autoWireToggle("showVerboseLog", {
renderObsidianSetting(paneEl, "showVerboseLog", {
onUpdate: visibleOnly(() => this.isConfiguredAs("lessInformationInLog", false)),
});
});
@@ -25,6 +25,7 @@ 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 {
@@ -87,14 +88,14 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
eventHub.emitEvent(EVENT_REQUEST_CHECK_REMOTE_SIZE);
})
);
new Setting(paneEl).autoWireToggle("writeLogToTheFile");
renderObsidianSetting(paneEl, "writeLogToTheFile");
});
void addPanel(paneEl, "Scram Switches").then((paneEl) => {
new Setting(paneEl).autoWireToggle("suspendFileWatching");
renderObsidianSetting(paneEl, "suspendFileWatching");
this.addOnSaved("suspendFileWatching", () => this.services.appLifecycle.askRestart());
new Setting(paneEl).autoWireToggle("suspendParseReplicationResult");
renderObsidianSetting(paneEl, "suspendParseReplicationResult");
this.addOnSaved("suspendParseReplicationResult", () => this.services.appLifecycle.askRestart());
});
@@ -7,6 +7,7 @@ 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";
@@ -16,17 +17,17 @@ import { migrateDatabases } from "./settingUtils.ts";
export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
void addPanel(paneEl, "Compatibility (Metadata)").then((paneEl) => {
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("deleteMetadataOfDeletedFiles");
renderObsidianSetting(paneEl, "deleteMetadataOfDeletedFiles").setClass("wizardHidden");
new Setting(paneEl).setClass("wizardHidden").autoWireNumeric("automaticallyDeleteMetadataOfDeletedFiles", {
renderObsidianSetting(paneEl, "automaticallyDeleteMetadataOfDeletedFiles", {
onUpdate: visibleOnly(() => this.isConfiguredAs("deleteMetadataOfDeletedFiles", true)),
});
}).setClass("wizardHidden");
});
void addPanel(paneEl, "Compatibility (Conflict Behaviour)").then((paneEl) => {
paneEl.addClass("wizardHidden");
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("disableMarkdownAutoMerge");
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("writeDocumentsIfConflicted");
renderObsidianSetting(paneEl, "disableMarkdownAutoMerge").setClass("wizardHidden");
renderObsidianSetting(paneEl, "writeDocumentsIfConflicted").setClass("wizardHidden");
});
void addPanel(paneEl, "Compatibility (Database structure)").then((paneEl) => {
@@ -113,18 +114,18 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
});
}
}
new Setting(paneEl).autoWireToggle("handleFilenameCaseSensitive", { holdValue: true }).setClass("wizardHidden");
renderObsidianSetting(paneEl, "handleFilenameCaseSensitive", { holdValue: true }).setClass("wizardHidden");
});
void addPanel(paneEl, "Compatibility (Internal API Usage)").then((paneEl) => {
new Setting(paneEl).autoWireToggle("watchInternalFileChanges", { invert: true });
renderObsidianSetting(paneEl, "watchInternalFileChanges", { invert: true });
});
void addPanel(paneEl, "Compatibility (Remote Database)").then((paneEl) => {
new Setting(paneEl).autoWireDropDown("E2EEAlgorithm", {
renderObsidianSetting(paneEl, "E2EEAlgorithm", {
options: E2EEAlgorithmNames,
});
});
new Setting(paneEl).autoWireToggle("useDynamicIterationCount", {
renderObsidianSetting(paneEl, "useDynamicIterationCount", {
holdValue: true,
onUpdate: visibleOnly(
() =>
@@ -134,16 +135,15 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
});
void addPanel(paneEl, "Edge case addressing (Database)").then((paneEl) => {
new Setting(paneEl)
.autoWireText("additionalSuffixOfDatabaseName", { holdValue: true })
.addApplyButton(["additionalSuffixOfDatabaseName"]);
renderObsidianSetting(paneEl, "additionalSuffixOfDatabaseName");
renderObsidianApplyButton(paneEl, "database-suffix");
this.addOnSaved("additionalSuffixOfDatabaseName", async (key) => {
Logger("Suffix has been changed. Reopening database...", LOG_LEVEL_NOTICE);
await this.services.databaseEvents.initialiseDatabase();
});
new Setting(paneEl).autoWireDropDown("hashAlg", {
renderObsidianSetting(paneEl, "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) => {
new Setting(paneEl).autoWireToggle("doNotSuspendOnFetching");
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("doNotDeleteFolder");
new Setting(paneEl).autoWireToggle("processSizeMismatchedFiles");
renderObsidianSetting(paneEl, "doNotSuspendOnFetching");
renderObsidianSetting(paneEl, "doNotDeleteFolder").setClass("wizardHidden");
renderObsidianSetting(paneEl, "processSizeMismatchedFiles");
});
void addPanel(paneEl, "Edge case addressing (Processing)").then((paneEl) => {
new Setting(paneEl).autoWireToggle("disableWorkerForGeneratingChunks");
renderObsidianSetting(paneEl, "disableWorkerForGeneratingChunks");
new Setting(paneEl).autoWireToggle("processSmallFilesInUIThread", {
renderObsidianSetting(paneEl, "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) => {
new Setting(paneEl).autoWireToggle("disableCheckingConfigMismatch");
renderObsidianSetting(paneEl, "disableCheckingConfigMismatch");
});
void addPanel(paneEl, "Remediation").then((paneEl) => {
let dateEl: HTMLSpanElement;
new Setting(paneEl)
const remediationSetting = 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")
.addApplyButton(["maxMTimeForReflectEvents"]);
.setAuto("maxMTimeForReflectEvents");
addObsidianApplyButton(remediationSetting, "remediation-reflect-events");
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");
new Setting(paneEl).autoWireToggle("enableCompression").setClass("wizardHidden");
renderObsidianSetting(paneEl, "enableCompression").setClass("wizardHidden");
});
}
@@ -1,5 +1,5 @@
import { type ConfigPassphraseStore } from "@lib/common/types.ts";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
import { renderObsidianApplyButton, renderObsidianSetting } from "./ObsidianSettingRenderer.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import type { PageFunctions } from "./SettingPane.ts";
@@ -21,14 +21,14 @@ export function panePowerUsers(
this.onlyOnCouchDB
).addClass("wizardHidden");
new Setting(paneEl)
.setClass("wizardHidden")
.autoWireNumeric("batch_size", { clampMin: 2, onUpdate: this.onlyOnCouchDB });
new Setting(paneEl).setClass("wizardHidden").autoWireNumeric("batches_limit", {
renderObsidianSetting(paneEl, "batch_size", { clampMin: 2, onUpdate: this.onlyOnCouchDB }).setClass(
"wizardHidden"
);
renderObsidianSetting(paneEl, "batches_limit", {
clampMin: 2,
onUpdate: this.onlyOnCouchDB,
});
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("useTimeouts", { onUpdate: this.onlyOnCouchDB });
}).setClass("wizardHidden");
renderObsidianSetting(paneEl, "useTimeouts", { onUpdate: this.onlyOnCouchDB }).setClass("wizardHidden");
});
void addPanel(paneEl, "Configuration Encryption").then((paneEl) => {
const passphrase_options: Record<ConfigPassphraseStore, string> = {
@@ -37,23 +37,18 @@ export function panePowerUsers(
ASK_AT_LAUNCH: "Ask an passphrase at every launch",
};
new Setting(paneEl)
.setName("Encrypting sensitive configuration items")
.autoWireDropDown("configPassphraseStore", {
options: passphrase_options,
holdValue: true,
})
.setClass("wizardHidden");
renderObsidianSetting(paneEl, "configPassphraseStore", {
options: passphrase_options,
}).setClass("wizardHidden");
new Setting(paneEl)
.autoWireText("configPassphrase", { isPassword: true, holdValue: true })
renderObsidianSetting(paneEl, "configPassphrase")
.setClass("wizardHidden")
.addOnUpdate(() => ({
disabled: !this.isConfiguredAs("configPassphraseStore", "LOCALSTORAGE"),
}));
new Setting(paneEl).addApplyButton(["configPassphrase", "configPassphraseStore"]).setClass("wizardHidden");
renderObsidianApplyButton(paneEl, "configuration-encryption").setClass("wizardHidden");
});
void addPanel(paneEl, "Developer").then((paneEl) => {
new Setting(paneEl).autoWireToggle("enableDebugTools").setClass("wizardHidden");
renderObsidianSetting(paneEl, "enableDebugTools").setClass("wizardHidden");
});
}
@@ -10,6 +10,7 @@ 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";
@@ -674,7 +675,7 @@ export function paneRemoteConfig(
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleNotification"), () => {}).then((paneEl) => {
paneEl.addClass("wizardHidden");
new Setting(paneEl).autoWireNumeric("notifyThresholdOfRemoteStorageSize", {}).setClass("wizardHidden");
renderObsidianSetting(paneEl, "notifyThresholdOfRemoteStorageSize", {}).setClass("wizardHidden");
});
// new Setting(paneEl).setClass("wizardOnly").addButton((button) =>
@@ -4,6 +4,7 @@ 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 {
@@ -46,12 +47,12 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
},
},
});
new Setting(paneEl).setClass("wizardHidden").autoWireNumeric("syncMaxSizeInMB", { clampMin: 0 });
renderObsidianSetting(paneEl, "syncMaxSizeInMB", { clampMin: 0 }).setClass("wizardHidden");
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("useIgnoreFiles");
new Setting(paneEl).setClass("wizardHidden").autoWireTextArea("ignoreFiles", {
renderObsidianSetting(paneEl, "useIgnoreFiles").setClass("wizardHidden");
renderObsidianSetting(paneEl, "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,6 +15,7 @@ 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,
@@ -107,10 +108,9 @@ export function paneSetup(
});
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleExtraFeatures")).then((paneEl) => {
new Setting(paneEl).autoWireToggle("useAdvancedMode");
new Setting(paneEl).autoWireToggle("usePowerUserMode");
new Setting(paneEl).autoWireToggle("useEdgeCaseMode");
renderObsidianSetting(paneEl, "useAdvancedMode");
renderObsidianSetting(paneEl, "usePowerUserMode");
renderObsidianSetting(paneEl, "useEdgeCaseMode");
this.addOnSaved("useAdvancedMode", () => this.display());
this.addOnSaved("usePowerUserMode", () => this.display());
@@ -2,6 +2,7 @@ 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";
@@ -31,18 +32,16 @@ export function paneSyncSettings(
DISABLE: $msg("obsidianLiveSyncSettingTab.optionDisableAllAutomatic"),
};
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();
});
renderObsidianSetting(paneEl, "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 == "") {
@@ -136,7 +135,7 @@ export function paneSyncSettings(
const onlyOnNonLiveSync = visibleOnly(() => !this.isConfiguredAs("syncMode", "LIVESYNC"));
const onlyOnPeriodic = visibleOnly(() => this.isConfiguredAs("syncMode", "PERIODIC"));
const optionsSyncMode =
const optionsSyncMode: Record<string, string> =
this.editingSettings.remoteType == REMOTE_COUCHDB
? {
ONEVENTS: $msg("obsidianLiveSyncSettingTab.optionOnEvents"),
@@ -148,12 +147,9 @@ export function paneSyncSettings(
PERIODIC: $msg("obsidianLiveSyncSettingTab.optionPeriodicAndEvents"),
};
new Setting(paneEl)
.autoWireDropDown("syncMode", {
//@ts-ignore
options: optionsSyncMode,
})
.setClass("wizardHidden");
renderObsidianSetting(paneEl, "syncMode", {
options: optionsSyncMode,
}).setClass("wizardHidden");
this.addOnSaved("syncMode", async (value) => {
this.editingSettings.liveSync = false;
this.editingSettings.periodicReplication = false;
@@ -167,32 +163,28 @@ export function paneSyncSettings(
await this.services.control.applySettings();
});
new Setting(paneEl)
.autoWireNumeric("periodicReplicationInterval", {
clampMax: 5000,
onUpdate: onlyOnPeriodic,
})
.setClass("wizardHidden");
renderObsidianSetting(paneEl, "periodicReplicationInterval", {
clampMax: 5000,
onUpdate: onlyOnPeriodic,
}).setClass("wizardHidden");
new Setting(paneEl).autoWireNumeric("syncMinimumInterval", {
renderObsidianSetting(paneEl, "syncMinimumInterval", {
onUpdate: onlyOnNonLiveSync,
});
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 });
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");
// 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()) {
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("keepReplicationActiveInBackground", {
renderObsidianSetting(paneEl, "keepReplicationActiveInBackground", {
onUpdate: visibleOnly(
() => this.isConfiguredAs("syncMode", "LIVESYNC") || this.isConfiguredAs("syncMode", "PERIODIC")
),
});
}).setClass("wizardHidden");
}
});
@@ -203,15 +195,15 @@ export function paneSyncSettings(
visibleOnly(() => !this.isConfiguredAs("syncMode", "LIVESYNC"))
).then((paneEl) => {
paneEl.addClass("wizardHidden");
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("batchSave");
new Setting(paneEl).setClass("wizardHidden").autoWireNumeric("batchSaveMinimumDelay", {
renderObsidianSetting(paneEl, "batchSave").setClass("wizardHidden");
renderObsidianSetting(paneEl, "batchSaveMinimumDelay", {
acceptZero: true,
onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)),
});
new Setting(paneEl).setClass("wizardHidden").autoWireNumeric("batchSaveMaximumDelay", {
}).setClass("wizardHidden");
renderObsidianSetting(paneEl, "batchSaveMaximumDelay", {
acceptZero: true,
onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)),
});
}).setClass("wizardHidden");
});
void addPanel(
@@ -222,9 +214,9 @@ export function paneSyncSettings(
LEVEL_ADVANCED
).then((paneEl) => {
paneEl.addClass("wizardHidden");
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("trashInsteadDelete");
renderObsidianSetting(paneEl, "trashInsteadDelete").setClass("wizardHidden");
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("doNotDeleteFolder");
renderObsidianSetting(paneEl, "doNotDeleteFolder").setClass("wizardHidden");
});
void addPanel(
paneEl,
@@ -235,11 +227,11 @@ export function paneSyncSettings(
).then((paneEl) => {
paneEl.addClass("wizardHidden");
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("resolveConflictsByNewerFile");
renderObsidianSetting(paneEl, "resolveConflictsByNewerFile").setClass("wizardHidden");
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("checkConflictOnlyOnOpen");
renderObsidianSetting(paneEl, "checkConflictOnlyOnOpen").setClass("wizardHidden");
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("showMergeDialogOnlyOnActive");
renderObsidianSetting(paneEl, "showMergeDialogOnlyOnActive").setClass("wizardHidden");
});
void addPanel(
@@ -250,11 +242,12 @@ export function paneSyncSettings(
LEVEL_ADVANCED
).then((paneEl) => {
paneEl.addClass("wizardHidden");
new Setting(paneEl).autoWireText("settingSyncFile", { holdValue: true }).addApplyButton(["settingSyncFile"]);
renderObsidianSetting(paneEl, "settingSyncFile");
renderObsidianApplyButton(paneEl, "setting-sync-file");
new Setting(paneEl).autoWireToggle("writeCredentialsForSettingSync");
renderObsidianSetting(paneEl, "writeCredentialsForSettingSync");
new Setting(paneEl).autoWireToggle("notifyAllSettingSyncFile");
renderObsidianSetting(paneEl, "notifyAllSettingSyncFile");
});
void addPanel(
@@ -313,14 +306,14 @@ export function paneSyncSettings(
});
}
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("suppressNotifyHiddenFilesChange", {});
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("syncInternalFilesBeforeReplication", {
renderObsidianSetting(paneEl, "suppressNotifyHiddenFilesChange", {}).setClass("wizardHidden");
renderObsidianSetting(paneEl, "syncInternalFilesBeforeReplication", {
onUpdate: visibleOnly(() => this.isConfiguredAs("watchInternalFileChanges", true)),
});
}).setClass("wizardHidden");
new Setting(paneEl).setClass("wizardHidden").autoWireNumeric("syncInternalFilesInterval", {
renderObsidianSetting(paneEl, "syncInternalFilesInterval", {
clampMin: 10,
acceptZero: true,
});
}).setClass("wizardHidden");
});
}
@@ -1,5 +1,11 @@
import { $msg } from "@lib/common/i18n";
import { LEVEL_ADVANCED, LEVEL_EDGE_CASE, LEVEL_POWER_USER, type ConfigLevel } from "@lib/common/types";
import {
LEVEL_ADVANCED,
LEVEL_EDGE_CASE,
LEVEL_POWER_USER,
type ConfigLevel,
type SettingDefinition,
} from "@lib/common/types";
import type { AllSettingItemKey, AllSettings } from "./settingConstants";
export const combineOnUpdate = (func1: OnUpdateFunc, func2: OnUpdateFunc): OnUpdateFunc => {
@@ -77,6 +83,7 @@ export type AutoWireOption = {
invert?: boolean;
onUpdate?: OnUpdateFunc;
obsolete?: boolean;
settingDefinition?: SettingDefinition<AllSettingItemKey>;
};
export function findAttrFromParent(el: HTMLElement, attr: string): string {
+36 -18
View File
@@ -3,24 +3,6 @@ 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
## 0.25.80
7th July, 2026
### Fixed
- Improved Markdown conflict auto-merge so that non-overlapping edits are merged while overlapping delete-and-edit cases remain visible for manual resolution (#993).
- Behaviour change:
- When one side deletes an unchanged line and the other side edits a different region, the deleted line is no longer reintroduced into the merged result.
- When one side deletes a line and the other side modifies that same line, the conflict is preserved instead of silently choosing one side.
- Fixed an issue where applying a newer database entry to storage could incorrectly preserve an older local file as a conflict (#994).
- Behaviour change:
- Local storage is preserved as a conflict when it may contain unsynchronised changes that are not represented in the revision history. A newer incoming text entry is applied without creating a conflict only when it clearly extends the existing local text.
- Fixed an issue where choosing Disable and then Overwrite in Hidden File Sync could silently skip hidden files, because the overwrite setup ran while hidden file synchronisation was still disabled (#989, PR #992).
- Hidden File Sync is now re-enabled before the Fetch, Overwrite, or Merge initialisation runs, instead of after it completes. If that initialisation fails, the setting may remain enabled.
## 0.25.79
29th June, 2026
@@ -114,5 +96,41 @@ Also, this update is a very large one, even if we had a lot of time, and we had
- Some dependencies have been updated.
- Now we check the compatibility with iOS 15 in the CI tests to ensure the plugin continues to work on older iOS versions even after we upgrade some dependencies.
## 0.25.74
8th June, 2026
### Fixed
- Fixed an issue where disabling hidden file synchronisation did not take effect, allowing non-target hidden files to continue to be processed and synchronised by replication or boot-sequence scan (#941).
- Prevented the automatic merging of conflicted revisions when one of the revisions has been deleted, which was causing deleted files to reappear (#911).
- The startup sequence now saves the state more effectively (Thank you so much for @bmcyver)!
## Only CLI
8th June, 2026
I should also consider the version numbering for the CLI...
### Improved
- Added new remote database management commands: `remote-status`, `unlock-remote`, `lock-remote`, and `mark-resolved`.
- --vault option is now available for daemon and mirror commands! (Thank you so much for @starskyzheng)!
- Decoupled the database directory path from the actual vault directory path using the `--vault` (or `-V`) option.
### Fixed (preventive)
- Validated that the specified vault path exists and is indeed a directory before starting the CLI.
- Integrated path resolution and validations for one-off commands (such as `'push'`, `'pull'`, `'cat'`, `'rm'`, `'info'`, and `'resolve'`) against the decoupled vault path instead of the database path.
## 0.25.73
4th June, 2026
### Fixed
- Adjust CouchDB's database name checking to its specification (#926).
- `Reset Syncronisation on This Device` for minio and P2P is now working properly.
Full notes are in
[updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md).
-58
View File
@@ -4,64 +4,6 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025)
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
## 0.25.80
7th July, 2026
### Fixed
- Improved Markdown conflict auto-merge so that non-overlapping edits are merged while overlapping delete-and-edit cases remain visible for manual resolution (#993).
- Behaviour change:
- When one side deletes an unchanged line and the other side edits a different region, the deleted line is no longer reintroduced into the merged result.
- When one side deletes a line and the other side modifies that same line, the conflict is preserved instead of silently choosing one side.
- Fixed an issue where applying a newer database entry to storage could incorrectly preserve an older local file as a conflict (#994).
- Behaviour change:
- Local storage is preserved as a conflict when it may contain unsynchronised changes that are not represented in the revision history. A newer incoming text entry is applied without creating a conflict only when it clearly extends the existing local text.
- Fixed an issue where choosing Disable and then Overwrite in Hidden File Sync could silently skip hidden files, because the overwrite setup ran while hidden file synchronisation was still disabled (#989, PR #992).
- Hidden File Sync is now re-enabled before the Fetch, Overwrite, or Merge initialisation runs, instead of after it completes. If that initialisation fails, the setting may remain enabled.
## 0.25.79
29th June, 2026
### Fixed
- Fast Fetch now retries transient stream interruptions and resumes from the latest persisted checkpoint, instead of starting over after ordinary network or platform interruptions (#977, PR #978; commonlib PR #59). Thank you so much for @apple-ouyang for the fix!
- Simple Fetch now remembers the selected setup choices while an interrupted Fetch All operation is still pending, so users are not asked the same questions again on retry (#977, PR #978). Thank you so much for @apple-ouyang for the fix!
- No longer hidden storage events, such as `.git` paths, reach the normal target-file filter when internal file synchronisation is disabled. This avoids noisy non-target logs before those files are skipped (commonlib PR #60). Thank you so much for @apple-ouyang for the fix!
- Fixed an issue where a file deleted from storage could be resurrected by the offline scanner because the database tombstone was not written when the storage file was already gone (commonlib PR #56). Thank you so much for @cosmic-fire-eng for the fix!
### Improved
- Local database maintenance commands now ask before applying the required chunk settings, and can apply those prerequisites before continuing (#980, PR #981). Thank you so much for @apple-ouyang for the improvement!
- Improved CouchDB replication event handling by using the new `StreamInbox` helper from `octagonal-wheels` (commonlib PR #62).
### Documentation
- Added `nginx` to the setup documentation table of contents (PR #976). Thank you so much for @kiraventom for the improvement!
### Miscellaneous
- Updated `octagonal-wheels` to `0.1.47` across the plug-in and workspace packages to use the newly published helper modules.
## 0.25.78
23rd June, 2026
### Fixed
- No longer fast synchronisation (a.k.a. Fast Fetch) causes a rewind and re-fetch of the entire database when some errors occur during the process (#972, PR #973). Thank you so much for @apple-ouyang for the fix!
### Improved
- Overhauled the Object Storage (e.g., MinIO and S3) replication engine ('Journal Replicator 2nd Edition').
- It now leverages the standard Web Streams API for a resilient, backpressure-aware architecture, reducing memory footprints/temporary storage usage on large vaults.
- Decoupled the physical storage logic to make it easier to add new storage backends in the future.
- Stricter compliance with CouchDB's replication protocol (proper `_revisions` transfers with `new_edits: false`) when using Object Storage.
### Testing
- Added comprehensive unit tests for the new `JournalSyncCore` engine, covering streams, backpressure, and `new_edits: false` validation.
- Improved integration test workflows in the CI pipeline to run MinIO tests automatically using standard environment variables.
## 0.25.77
19th June, 2026
-140
View File
@@ -1,140 +0,0 @@
import { readFileSync, writeFileSync } from "fs";
const updatesPath = "updates.md";
// Utility used by the release workflows to rotate and validate `updates.md`.
// It intentionally keeps the Markdown format simple: top-level `##` headings
// are treated as release boundaries, and only one `## Unreleased` section is
// allowed.
function fail(message) {
console.error(message);
process.exit(1);
}
function readJson(path) {
return JSON.parse(readFileSync(path, "utf8"));
}
function assertVersion(version) {
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
fail(`Invalid release version: ${version}`);
}
}
function formatReleaseDate(date = new Date()) {
const day = date.getUTCDate();
const suffix = day % 10 === 1 && day !== 11 ? "st" : day % 10 === 2 && day !== 12 ? "nd" : day % 10 === 3 && day !== 13 ? "rd" : "th";
const month = new Intl.DateTimeFormat("en-GB", { month: "long", timeZone: "UTC" }).format(date);
const year = date.getUTCFullYear();
return `${day}${suffix} ${month}, ${year}`;
}
function headingPattern(heading) {
return new RegExp(`^## ${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
}
// Return a `##` section body without interpreting lower-level headings.
function findSection(markdown, heading) {
const pattern = headingPattern(heading);
const match = pattern.exec(markdown);
if (!match) return undefined;
const headingStart = match.index;
const bodyStart = match.index + match[0].length;
const rest = markdown.slice(bodyStart);
const next = /^## .+$/m.exec(rest);
const end = next ? bodyStart + next.index : markdown.length;
return {
headingStart,
bodyStart,
end,
body: markdown.slice(bodyStart, end),
};
}
function assertSingleUnreleased(markdown) {
const matches = markdown.match(/^## Unreleased\s*$/gm) || [];
if (matches.length !== 1) {
fail(`Expected exactly one '## Unreleased' section in ${updatesPath}, found ${matches.length}.`);
}
}
function prepare(version) {
assertVersion(version);
const markdown = readFileSync(updatesPath, "utf8");
assertSingleUnreleased(markdown);
if (headingPattern(version).test(markdown)) {
fail(`Release notes for ${version} already exist in ${updatesPath}.`);
}
const unreleased = findSection(markdown, "Unreleased");
if (!unreleased) fail(`Could not find '## Unreleased' in ${updatesPath}.`);
const allowEmpty = process.env.ALLOW_EMPTY_UPDATES === "true";
if (!allowEmpty && unreleased.body.trim() === "") {
fail(`The '## Unreleased' section is empty. Set ALLOW_EMPTY_UPDATES=true if this is intentional.`);
}
// Keep a fresh empty Unreleased section above the newly dated release notes.
const releasedBody = `${unreleased.body.trim()}\n\n`;
const releaseDate = process.env.RELEASE_DATE || formatReleaseDate();
const replacement = `## Unreleased\n\n## ${version}\n\n${releaseDate}\n\n${releasedBody}`;
const nextMarkdown = markdown.slice(0, unreleased.headingStart) + replacement + markdown.slice(unreleased.end);
writeFileSync(updatesPath, nextMarkdown, "utf8");
}
function validate(version) {
assertVersion(version);
const rootPackage = readJson("package.json");
const manifest = readJson("manifest.json");
if (rootPackage.version !== version) {
fail(`package.json version is ${rootPackage.version}, expected ${version}.`);
}
if (manifest.version !== version) {
fail(`manifest.json version is ${manifest.version}, expected ${version}.`);
}
for (const workspace of ["cli", "webpeer", "webapp"]) {
const workspacePackage = readJson(`src/apps/${workspace}/package.json`);
const expected = `${version}-${workspace}`;
if (workspacePackage.version !== expected) {
fail(`src/apps/${workspace}/package.json version is ${workspacePackage.version}, expected ${expected}.`);
}
}
const versions = readJson("versions.json");
if (versions[version] !== manifest.minAppVersion) {
fail(`versions.json does not map ${version} to manifest minAppVersion ${manifest.minAppVersion}.`);
}
const markdown = readFileSync(updatesPath, "utf8");
assertSingleUnreleased(markdown);
const releaseSection = findSection(markdown, version);
if (!releaseSection) {
fail(`Could not find '## ${version}' in ${updatesPath}.`);
}
if (releaseSection.body.trim() === "") {
fail(`The release notes for ${version} are empty.`);
}
if (/\b(?:TODO|WIP)\b/i.test(releaseSection.body)) {
fail(`The release notes for ${version} still contain TODO or WIP markers.`);
}
}
const [command, version] = process.argv.slice(2);
if (!command || !version) {
fail("Usage: node utils/release-notes.mjs <prepare|validate> <version>");
}
if (command === "prepare") {
prepare(version);
} else if (command === "validate") {
validate(version);
} else {
fail(`Unknown command: ${command}`);
}
+2 -2
View File
@@ -11,7 +11,7 @@ writeFileSync("manifest.json", JSON.stringify(manifest, null, 4));
// update versions.json with target version and minAppVersion from manifest.json
// but only if the target version is not already in versions.json
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
if (!(targetVersion in versions)) {
if (!Object.values(versions).includes(minAppVersion)) {
versions[targetVersion] = minAppVersion;
writeFileSync('versions.json', JSON.stringify(versions, null, 4));
}
}