mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-31 13:28:18 +00:00
Merge remote-tracking branch 'upstream/main' into feat/syncguard-bulk-sync-edit-guard
# Conflicts: # src/common/messages/LiveSyncProvisionalMessages.ts # styles.css # updates.md
This commit is contained in:
commit
acd3b0ade6
157 files changed
+15374
-1051
No files matched your search
@@ -13,6 +13,7 @@ on:
|
||||
- 'src/**'
|
||||
- 'test/**'
|
||||
- '_tools/**'
|
||||
- 'scripts/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'tsconfig.json'
|
||||
@@ -51,6 +52,7 @@ on:
|
||||
- 'src/**'
|
||||
- 'test/**'
|
||||
- '_tools/**'
|
||||
- 'scripts/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'tsconfig.json'
|
||||
|
||||
@@ -84,6 +84,7 @@ Synchronisation status is shown in the status bar with the following icons.
|
||||
- 🌐N Approximate remote requests currently in progress
|
||||
- Status
|
||||
- ⏹️ Stopped
|
||||
- ⏩ Comparing CouchDB change history, including entries already held locally
|
||||
- 💤 LiveSync enabled. Waiting for changes
|
||||
- ⚡️ Synchronisation in progress
|
||||
- ⚠ An error occurred
|
||||
@@ -104,6 +105,25 @@ Synchronisation status is shown in the status bar with the following icons.
|
||||
|
||||
To prevent file and database corruption, please avoid closing Obsidian until all progress indicators have disappeared as much as possible (although the plug-in will attempt to resume if interrupted). This is especially important if you have deleted or renamed files.
|
||||
|
||||
When CouchDB change history is being compared, `(⏩ 1200) ↑ 0 ↓ 0` can appear before any new documents arrive. The count is the number of candidate changes-feed entries compared during the current replication attempt, including repeated entries on retries. It is separate from downloaded Chunks and Metadata, and is neither a Vault file count nor a count of entries already present locally. It appears before the upload count, disappears after three seconds without a change, and reappears when the count changes. The ⏩ status icon follows comparison activity independently of that timer.
|
||||
|
||||
### Customising status items with CSS snippets
|
||||
|
||||
The status bar and editor overlay use individual spans with the common `livesync-stat-` class prefix. CSS snippets can hide or style the items we need. For example, this hides the comparison count and P2P summary:
|
||||
|
||||
```css
|
||||
.livesync-stat-ff,
|
||||
.livesync-stat-p2p {
|
||||
display: none;
|
||||
}
|
||||
```
|
||||
|
||||
The item classes include `livesync-stat-network`, `livesync-stat-sync`, `livesync-stat-sent`, `livesync-stat-push-remaining`, `livesync-stat-arrived`, `livesync-stat-pull-remaining`, `livesync-stat-ff`, and `livesync-stat-p2p`. File-processing items use `livesync-stat-file-processing`, `livesync-stat-file-pending`, and `livesync-stat-file-batched`; queue items use `livesync-stat-queue-` followed by `replication`, `database`, `storage`, `chunk-initial`, `chunk-retrying`, `plugin-scan`, `hidden-files`, or `conflicts`. The skipped-file indicator uses `livesync-stat-file-skip`.
|
||||
|
||||
All items also have `livesync-stat-item`. Numeric children use `livesync-stat-value`, tabular digits, and right alignment. Each numeric item retains its widest displayed digit count during an active period, reducing movement as counts decrease. The editor overlay uses wider gaps between items, while remaining counts stay beside their transfer totals.
|
||||
|
||||
Inactive items receive `livesync-stat-inactive` instead of the HTML `hidden` attribute. The default CSS hides them immediately. They retain their last text, numeric width, and position amongst the other items so that CSS snippets can customise how they disappear. The numeric width starts afresh when an item becomes active again.
|
||||
|
||||
## Tips and Troubleshooting
|
||||
- If you want a faster and simpler initial replication when setting up subsequent devices, see the [Fast Setup Guide](docs/tips/fast-setup.md).
|
||||
- Configure [Hidden File Sync](docs/tips/hidden-file-sync.md) only after ordinary note synchronisation works.
|
||||
|
||||
@@ -166,6 +166,14 @@ Legacy Modules remain grouped by directory:
|
||||
- `features/` contains optional features; and
|
||||
- `extras/` contains development and testing tools.
|
||||
|
||||
### UI commands and dialogue ownership
|
||||
|
||||
Declare shared UI requests with `declareUICommand<Request, Result>` from `@vrtmrz/livesync-commonlib/ui-commands`. The host's `services.UI.commands` registry owns one handler per declaration. A serviceFeature registers the handler, releases it on unload, and owns the associated dialogue lifetime. Callers receive a typed result or an explicit `unavailable` result; handler failures propagate.
|
||||
|
||||
`OPEN_JOINED_DEVICES` is shared by Remote Configuration, version Notices, and the command palette. `useJoinedDevicesFeature` registers it once and gives the dialogue narrow refresh, forget, and cancellation callbacks. Each remote operation uses a settings snapshot and releases its finite resource in `finally`. Results from an old remote configuration or a closed dialogue are discarded. A write already sent to the captured remote can still complete after the dialogue closes.
|
||||
|
||||
The registry belongs to one UIService instance. Reuse the same declaration object at each call site, and keep active UI cleanup in the feature and dialogue owner. `UIService` disposes the registry when the host's context emits the plug-in-unloaded event.
|
||||
|
||||
### Key Architectural Components
|
||||
|
||||
- **LiveSyncLocalDB** (`@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB`): Local PouchDB database wrapper
|
||||
@@ -204,6 +212,9 @@ The [multiple-device conflict test procedure](test/README.md#multiple-device-con
|
||||
|
||||
### File Structure Conventions
|
||||
|
||||
- **Class modules**: Start a new or renamed TypeScript or JavaScript filename with an upper-case letter when the file contains a class with the same name.
|
||||
- **Function modules**: Start the filename with a lower-case letter when the module primarily exports functions, such as `initialFetchFastForward.ts`. Follow the surrounding filename style for the rest of the basename. Corresponding test files use the same basename.
|
||||
- **Existing files**: Retain established names for unrelated files and separate conventions for components or other file types.
|
||||
- **Platform-specific code**: Use `.platform.ts` suffix (replaced with `.obsidian.ts` in production builds via esbuild)
|
||||
- **Development code**: Use `.dev.ts` suffix (replaced with `.prod.ts` in production)
|
||||
- **Path aliases**: `@/*` maps to `src/*`; Commonlib uses package exports rather than a source alias
|
||||
@@ -309,6 +320,8 @@ Existing legacy Modules continue to register their handlers in `onBindFunction()
|
||||
database and Replicator readiness. See
|
||||
[the declarative settings adapter ADR](docs/adr/2026_08_declarative_settings_adapter.md).
|
||||
- Use `this.services.setting.saveSettingData()` instead of using plugin methods directly
|
||||
- Inject `@vrtmrz/value-store` at host composition. `setting.deviceValues` uses exact device-local keys; `setting.config` and the Service Hub `config` share the existing Vault and database-suffix scope. Await reads, writes, and deletions before advancing the workflow. Browser and Obsidian hosts retain their Web Storage keys, and the CLI retains its existing flat JSON string map.
|
||||
- Seed the settings tab with `await settingTab.loadSettings()` before registration. Its editing buffer remains synchronous for Obsidian rendering; storage failures must not replace a previously loaded value with an empty default.
|
||||
|
||||
### Database Operations
|
||||
|
||||
@@ -340,6 +353,7 @@ This policy governs plug-in pre-releases and their accompanying CLI images. For
|
||||
- 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.
|
||||
- For a stable release, review the complete user-facing changes since the previous stable release. Classify new features and changed behaviour relative to that stable release. Include changes from intervening pre-releases which never became stable, even when their original notes have already moved into a release history.
|
||||
- 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.
|
||||
- Keep approximately the five most recent published plug-in versions in the embedded `updates.md`. Move older published sections unchanged into the appropriate release-line archive under `docs/releases/`, and update the history references when rotating them.
|
||||
|
||||
@@ -359,8 +373,9 @@ For a fix needed only by the CLI, use [CLI-only patch releases](#cli-only-patch-
|
||||
- Validate installation or update through BRAT, and cold-start Obsidian to confirm the plug-in loads. Verify ordinary bidirectional synchronisation and relevant regression scenarios in real Obsidian against the published artefacts, or a build with matching hashes. When automated scenarios establish that coverage, BRAT acceptance need only cover installation or update and start-up; manually check any remaining release-specific behaviour. Record the artefact hashes and validation evidence as described in the [real Obsidian test guide](test/e2e-obsidian/README.md#combined-setup-and-security-regression-checks).
|
||||
- After a hyphenated pre-release passes, keep its release pull request unmerged. Add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`, then close the release pull request only through a separate maintainer action.
|
||||
- After a stable version passes, remove the GitHub pre-release designation and make that exact release the latest stable release. Confirm that the release is no longer a pre-release, then mark its release pull request ready, merge the exact release commit into the selected base branch with a merge commit, and integrate that exact commit through the reviewed branch chain into the repository's default branch. Confirm that the default branch contains the matching stable metadata. Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate.
|
||||
- If BRAT validation fails, keep the release PR in draft and do not move published tags. Before preparing the next version, add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`. Keep only changes made after that tag under `## Unreleased`. Compare the historical section with `git show <tag>:updates.md`; do not merge the failed release PR or describe it as validated. The next release PR can then rotate only the correction notes while preserving the immutable release history.
|
||||
- Prepare and publish the next patch or pre-release version from that reconciled base. Leave the failed release PR draft until it is deliberately closed as superseded under a separate maintainer action.
|
||||
- If BRAT validation fails, keep the release PR in draft and leave its published tag and artefacts unchanged. Prepare the correction on a new branch descended from that exact release commit, retaining its existing changes and `versions.json` entry. Prepare the next patch or pre-release version on that branch, with its own version, tag, and release PR. The selected base branch remains unchanged until a successor stable release passes validation and is promoted.
|
||||
- For the successor stable release, consolidate all user-facing changes since the last stable release, including changes from intervening pre-releases which never became stable. Preserve each published pre-release's exact tagged notes in the appropriate history under `docs/releases/`, and compare the archived section with `git show <tag>:updates.md`. The new release notes must describe the whole stable upgrade, including the correction.
|
||||
- After the successor stable release is validated and promoted, merge its exact release commit and ancestry through the reviewed branch chain. Leave the failed release PR in draft until it is deliberately closed as superseded under a separate maintainer action.
|
||||
- A hyphenated version is rejected unless `prerelease=true`. A stable version staged with `prerelease=true` is rejected unless `publish_cli=false`.
|
||||
|
||||
### Release Cheat Sheet
|
||||
@@ -390,7 +405,7 @@ For a fix needed only by the CLI, use [CLI-only patch releases](#cli-only-patch-
|
||||
9. Validate the published release through BRAT, including start-up, ordinary bidirectional synchronisation, and any release-specific regression scenario.
|
||||
10. After a hyphenated pre-release passes, keep its release PR unmerged, reconcile its `versions.json` entry and exact release-note section into the selected base branch as metadata only, then close the PR through a separate maintainer action.
|
||||
11. After a stable version passes, remove the pre-release designation and make the exact release the latest stable release. Confirm that promotion, then mark the release PR ready, merge the exact release commit into the selected base branch, and integrate it through the reviewed branch chain into the repository's default branch. Confirm that the default branch contains the matching stable metadata, then publish stable CLI tags through a separate maintainer gate if selected.
|
||||
12. If validation fails, leave the PR in draft and do not move the published tags. Reconcile the published version's `updates.md` section and `versions.json` entry into the base branch as metadata only, then prepare the next patch or pre-release version from the remaining `## Unreleased` entries.
|
||||
12. If validation fails, leave the PR in draft and keep its published tag and artefacts unchanged. Start a new branch from that release commit, add the correction, and prepare the next patch or pre-release version. Retain the published version's `versions.json` entry and exact archived notes, and consolidate the successor stable release notes from the last stable release. Keep the base branch unchanged until the successor stable release is validated and promoted.
|
||||
|
||||
### CLI-only patch releases
|
||||
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
---
|
||||
date: 2026-10-24
|
||||
commonlib-version: "0.2.0"
|
||||
self-hosted-livesync-version: "1.0.40"
|
||||
status: accepted-unreleased
|
||||
---
|
||||
|
||||
# Architectural Decision Record: Local File Save Completion and Batching
|
||||
|
||||
## Status
|
||||
|
||||
Accepted on 2026-10-25. The package versions above identify the investigated
|
||||
baseline, rather than a released implementation of this decision.
|
||||
|
||||
## Context
|
||||
|
||||
[Issue #1207](https://github.com/vrtmrz/obsidian-livesync/issues/1207) includes
|
||||
conflicts around automated note creation, editing, and renaming. Batching local
|
||||
file updates can reduce the intermediate revisions which another device receives.
|
||||
It also extends the period during which the Vault contains an edit which the
|
||||
local database has not saved. Recovery and incoming-file protection must remain
|
||||
correct throughout that period.
|
||||
|
||||
Two-device Obsidian E2E tests using Self-hosted LiveSync 1.0.40 and Commonlib
|
||||
0.2.0 found that a test-only removal of the LiveSync mode restriction reduced
|
||||
six consecutive edits from six additional revisions to one, in both directions.
|
||||
Incoming identical content produced no extra conflict, while genuinely different
|
||||
edits or a deletion preserved the necessary branches. A synthetic
|
||||
create–edit–rename sequence still delivered intermediate content. These tests
|
||||
used Vault operations, without Templater or Modal Forms themselves.
|
||||
|
||||
Restart tests exposed a separate recovery gap:
|
||||
|
||||
1. The event manager removes an event from its waiting map when its timer is
|
||||
released. It also removes a buffered event before dispatching it. Its saved
|
||||
snapshot covers buffered and waiting events, but omits work in progress.
|
||||
2. `waitForIdle()` waits for the waiting gates to open, rather than for the file
|
||||
handlers to finish. `ServiceFileAccessBase` reports that pending events have
|
||||
been committed after this gate-only wait. Some handler failures also settle
|
||||
without propagating a failure to that caller.
|
||||
3. If the process stops before the database write, the file can retain the new
|
||||
content while the database retains the old content and the saved event
|
||||
snapshot is empty.
|
||||
4. At start-up, the Offline Scanner's `EVEN` branch tries to record missing
|
||||
provenance, then reports completion. It ignores that helper's Boolean result.
|
||||
Different content can consequently remain unsaved when both modification
|
||||
times fall in the same two-second comparison bucket.
|
||||
|
||||
The last condition was reproduced by pausing the target operation immediately
|
||||
before database processing, then restarting the same isolated profile. A
|
||||
different timestamp bucket recovered the edit; the same bucket did not. The
|
||||
changed-condition success demonstrates the boundary, rather than a fix. This
|
||||
does not establish the cause of the reporter's historical conflicts.
|
||||
|
||||
The runtime evidence covers Linux aarch64, Obsidian 1.12.7, and CouchDB 3.5.0.
|
||||
Windows, mobile lifecycles, and the reporter's full automation remain outside
|
||||
that evidence. The inspected Commonlib source is commit
|
||||
`aa54282937c20d3db7f43778d240feeb1586cced`.
|
||||
|
||||
## Decision
|
||||
|
||||
Make local file processing recoverable until its result is established, retain
|
||||
strict content and provenance reconciliation for manual and recovery scans, and
|
||||
enable batching in LiveSync mode only through an explicit choice after those
|
||||
contracts pass verification. Ordinary Obsidian start-up may skip a body read
|
||||
only under the exact-match conditions below. Apply the recovery contract with
|
||||
batching enabled or disabled.
|
||||
|
||||
The content-reconciliation change targets selected ordinary files in the
|
||||
Offline Scanner's `NEWER_WINS` / `sync-newer` action. Keep the existing action
|
||||
matrix, explicit `DB_APPLY` policy, selection and size limits, conflict guards,
|
||||
and recovery suspensions. Hidden and internal files retain their owning
|
||||
feature's recovery contract; an ordinary file scan cannot recover those paths
|
||||
on its behalf.
|
||||
|
||||
### 1. Distinguish local persistence from replication
|
||||
|
||||
The following boundaries have different meanings:
|
||||
|
||||
| Boundary | Meaning |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Batch wait released | The file operation may start. Its content can still be absent from the local database. |
|
||||
| Local processing completed | The intended change is represented durably by the local database and its required content, or has been proved already represented or safely superseded. |
|
||||
| Replication completed | A finite replication reports its own outcome. A local save alone does not prove remote receipt or reflection into another Vault. |
|
||||
|
||||
Ordinary file saving by Obsidian remains independent of database saving. Pending
|
||||
and processing indicators must include work in progress and unresolved failures,
|
||||
so an empty timer map cannot imply that local processing is complete.
|
||||
|
||||
### Optional file ownership and completion
|
||||
|
||||
Hidden File Sync and Customisation Sync register the new
|
||||
`processOptionalFileEventWithResult` contract together. `firstHandled` tries
|
||||
providers in order until one returns `completed` or `veto`; only
|
||||
`not-applicable` advances to the next provider. Exceptions propagate unchanged.
|
||||
`HandlerOutcome<TValue, TVetoReason>` gives completion data and refusal reasons
|
||||
independent types. These saves use `void` completion data and string reasons.
|
||||
|
||||
A provider returns `completed` after its database operation succeeds or the
|
||||
current state is already represented. Failed writes and deletions throw, and
|
||||
the manager retains the event. Hidden File Sync acknowledges its processed
|
||||
fingerprint after successful processing. Customisation Sync waits for the real
|
||||
save after its existing 100ms quiet period. All notifications coalesced into a
|
||||
V1 group receive that save's result; V2 files retain separate save groups.
|
||||
|
||||
A finite flush resolves each captured event's `whenFlushRequested` signal. This
|
||||
releases its customisation delay and freezes that group; later groups do not
|
||||
extend its completion. During start-up replay or suspension, providers use
|
||||
physical database readiness rather than waiting for application readiness or
|
||||
resume. Hidden File Sync loads its existing per-database caches before replay
|
||||
and reuses them during the following initialisation hook.
|
||||
|
||||
The existing boolean optional API remains a legacy notification contract for
|
||||
other callers and older hosts. Once a host registers typed providers, the
|
||||
manager dispatches through that chain exclusively. Without a typed provider,
|
||||
a resolved legacy notification completes that dispatch only; its boolean does
|
||||
not establish a database save. Snapshot-write failures remain visible under
|
||||
both contracts. No persistent journal, new snapshot schema, or automatic retry
|
||||
loop is introduced.
|
||||
|
||||
### 2. Keep a recoverable record through processing
|
||||
|
||||
The event manager owns each operation from queue admission through completion.
|
||||
Its existing persistent snapshot must represent buffered, waiting, and
|
||||
in-progress work in an order which preserves deletion and rename dependencies.
|
||||
Moving work between those states must not create a persistent gap.
|
||||
|
||||
An event can leave the recoverable set only after its effect is durable, current
|
||||
state proves that the effect is already represented, or a durably recorded
|
||||
successor safely subsumes it. Finishing an older operation must not remove a
|
||||
newer operation for the same path. Cancellation of a batch timer alone is not
|
||||
evidence that its content is safe to forget.
|
||||
|
||||
Snapshot writes must preserve mutation order. An older asynchronous write must
|
||||
not replace a newer snapshot, and a failed snapshot write must remain visible
|
||||
to the lifecycle caller. Reuse the existing device-local snapshot store; a
|
||||
second journal and a transaction spanning the file system and database are not
|
||||
part of this proposal.
|
||||
|
||||
The first implementation should retain the existing event snapshot format,
|
||||
including event keys and rename intent, and add pending ownership within the
|
||||
event manager. The persistence adapter remains responsible for storing the
|
||||
ordered snapshots. Handler outcomes must distinguish a failed attempt from a
|
||||
policy-approved skip before the manager can acknowledge either safely.
|
||||
|
||||
A crash after the database write but before snapshot removal can replay an
|
||||
operation. Replay must check current storage and revision provenance, retain
|
||||
the existing protection against stale restored events, and never delete a
|
||||
newer same-path file. Once both the database write and provenance write have
|
||||
completed, replay must not add another revision. If the database write
|
||||
succeeded before provenance was recorded, replay may first create an identical
|
||||
sibling even when no remote descendant has arrived. In the tested case
|
||||
without a remote edit, replay creates that sibling. Duplicate handling then
|
||||
tombstones one of the identical branches and converges to one live leaf with
|
||||
the exact saved content.
|
||||
Another restart must add no revision. Replay must remain safe to interrupt;
|
||||
existing saved snapshots remain readable.
|
||||
|
||||
The database write and the device-local provenance write are separate,
|
||||
sequential operations. A stop between them can leave the previous provenance
|
||||
record in place. This pre-provenance gap is the known exception to the
|
||||
no-extra-revision result after both writes complete: replay can first create a
|
||||
duplicate sibling; one of the identical branches is then tombstoned while
|
||||
one live leaf retains the saved bytes when no remote descendant has arrived. If continuous
|
||||
replication advances the local database during the gap, replay can instead
|
||||
expose the local content as a sibling of that descendant. The [conflict
|
||||
resolution specification](../specs_conflict_resolution.md#known-potentially-problematic-behaviour)
|
||||
records the required mergeable and manual-resolution outcomes. The per-file
|
||||
lock does not fence replication writes. The interruption window is expected
|
||||
to be very rare, but its field frequency is unmeasured; this decision adds no
|
||||
transaction or second journal to close the interval.
|
||||
|
||||
Boolean failure, an exception, and an unavailable comparison are not successful
|
||||
completion. Keep unresolved work recoverable and report the failure through the
|
||||
existing service and diagnostic boundaries. Explicit exclusions or a verified
|
||||
superseding change may finish work without a new revision; a generic handler
|
||||
failure may not. Recovery may continue with independent paths, while retaining
|
||||
the failed path and its dependencies. This proposal adds no unbounded retry loop.
|
||||
|
||||
The recoverable snapshot is an operation record, not a backup of every transient
|
||||
editor buffer. File content already saved by the host remains available for
|
||||
start-up reconciliation if the process stops before the first snapshot write.
|
||||
Crash guarantees cannot include bytes or events which the host never persisted
|
||||
or delivered, or repair arbitrary storage failure.
|
||||
|
||||
### 3. Give flush a finite, truthful completion boundary
|
||||
|
||||
An explicit commit of pending file events captures the work accepted before
|
||||
that request, releases its batch delays, and waits for the actual processing
|
||||
results, including buffered and already-running work. Later edits cannot extend
|
||||
that boundary indefinitely. A newer coalesced operation may satisfy earlier work
|
||||
only when its successful result covers that work.
|
||||
|
||||
The internal ordering barrier before a delete or rename waits only for its
|
||||
predecessors. It must not wait for itself or later events in the same dispatch
|
||||
loop. Replacing every gate wait with a wait for global idleness would risk a
|
||||
dependency cycle or starvation.
|
||||
|
||||
Manual synchronisation and replication readiness use the completed local
|
||||
boundary. Settings application must observe its result before reporting success
|
||||
or resuming work which depends on it. Persisting a settings file is a separate
|
||||
operation: `saveSettingData()` returning is not a file-processing completion
|
||||
signal, and making every settings-saved notification synchronous is unnecessary.
|
||||
|
||||
When a host permits orderly shutdown, stop admitting watcher work before
|
||||
draining accepted operations or preserving their pending records. Keep their
|
||||
storage available until that work settles. Suspension, forced termination, and
|
||||
Obsidian's synchronous unload callback cannot guarantee that asynchronous work
|
||||
finishes; correctness must also follow from the persistent record and start-up
|
||||
reconciliation. Remote availability must not determine whether local pending
|
||||
work survives shutdown.
|
||||
|
||||
### 4. Preserve strict content reconciliation
|
||||
|
||||
Strict content checking retains the timestamp and provenance reconciliation
|
||||
contract for platforms with coarse modification times. Timestamp equality,
|
||||
including exactly equal raw timestamps, does not prove content equality. With
|
||||
strict checking enabled, the Offline Scanner must establish one of the
|
||||
following outcomes through the ordinary non-forced file handler and its
|
||||
provenance rules:
|
||||
|
||||
| Evidence | Required outcome |
|
||||
| ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Vault content already matches an eligible current revision | Record or retain valid provenance as appropriate; create no duplicate revision. |
|
||||
| Vault content matches its recorded revision or saved content evidence, while the current database revision has advanced | Apply the database state through the existing protected reflection path. Do not resurrect the unchanged older content as an edit. |
|
||||
| Vault content differs from its recorded revision | Preserve the edit on that recorded branch. Keep independent concurrent branches available for conflict resolution. |
|
||||
| Vault content has no provable origin and matches no current live leaf | Preserve it according to the existing independent-branch rules. Do not attach it to an arbitrary database winner. |
|
||||
| Required content or provenance cannot be read, or state changes during comparison | Retain unresolved work and report or re-evaluate it. Do not mark it reconciled merely because the timestamp comparison is `EVEN`. |
|
||||
|
||||
The `strictContentCheck` scan option is strict when omitted. Ordinary Obsidian
|
||||
start-up can opt out through **Strict content check on startup**, which is
|
||||
disabled by default. With the option disabled, a body read may be skipped only
|
||||
when the file's raw modification time, byte size, and valid device-local
|
||||
provenance exactly match a conflict-free current revision and no file
|
||||
processing is pending. All other files retain strict content reconciliation.
|
||||
An unrecorded edit that preserves its exact size and modification time can
|
||||
therefore remain undetected by ordinary start-up while the option is disabled.
|
||||
Manual scans, Fetch, Rebuild, and other explicit recovery operations remain
|
||||
strict. Pending-event replay, incoming-change protection, and deletion guards
|
||||
remain active in both modes.
|
||||
|
||||
The table describes stable-state strict reconciliation, not atomic database and
|
||||
provenance persistence. Replay in the pre-provenance gap may create an
|
||||
identical sibling; with no remote descendant, one of the identical branches
|
||||
is tombstoned, leaving one live leaf with the exact saved content. If replication has
|
||||
already added a descendant, preserve its exact content through an existing
|
||||
safe merge, or retain both branches for manual resolution. See [Known
|
||||
potentially problematic behaviour](../specs_conflict_resolution.md#known-potentially-problematic-behaviour).
|
||||
|
||||
Revalidate the relevant file and database state before acting on a comparison.
|
||||
Reuse the existing content and provenance checks; avoid a separate conflict
|
||||
algorithm in the scanner. Metadata continues to reference Chunks containing the
|
||||
file content.
|
||||
|
||||
`tryRecordUntrackedFileRevision()` answers whether it could safely record
|
||||
provenance. Its `false` also covers an already-populated record, conflicting
|
||||
history, unavailable content, and a concurrent change. It is not an 'edited
|
||||
file' predicate. A fallback which treats every `false` as permission for a
|
||||
forced save would violate the required outcomes.
|
||||
|
||||
Propagate the file handler's actual outcome into the pair result, including for
|
||||
storage-to-database writes. Preserve the scanner's `completed`, `skipped`, and
|
||||
`failed` distinctions and its existing `continueOnFileFailure` policy. Ordinary
|
||||
Obsidian start-up can report `completed-with-file-failures` and allow unaffected
|
||||
files to proceed; an unresolved pair must not become `completed` to achieve
|
||||
that result. Keep the stricter callers' existing completion requirements.
|
||||
|
||||
Keep the original unchanged-file, edited-file, untracked-provenance,
|
||||
incoming-protection, and concurrent-recheck fixtures under strict content
|
||||
checking, including identical timestamps and distinct raw timestamps within
|
||||
one comparison bucket. Do not weaken those strict-mode assertions to describe
|
||||
the ordinary start-up opt-out. Test the opt-out separately: only the exact
|
||||
stat, provenance, conflict-free, and no-pending-processing match may skip a
|
||||
body read. Pending-event replay and incoming or deletion guards remain covered
|
||||
with the opt-out enabled.
|
||||
|
||||
### 5. Make batch timing and activation explicit
|
||||
|
||||
Batching is a bounded delay before local database processing. The quiet-period
|
||||
timer follows the latest eligible content update; the maximum intentional delay
|
||||
starts with the first uncommitted update and is not reset by later edits or time
|
||||
spent awaiting a processing slot. Once due, work must become eligible to run.
|
||||
The maximum delay is not a guarantee of database completion, network delivery,
|
||||
or another device's reflection time. Waiting timers should not consume the
|
||||
capacity needed to process unrelated ready files.
|
||||
|
||||
For ordinary local content updates, queue the changed path and operation intent
|
||||
without reading the file body merely to wait. Acquire the current content when
|
||||
the operation becomes due, after coalescing eligible events. Reuse that content
|
||||
snapshot within the operation for comparison, provenance evidence, and Chunk
|
||||
creation. This retains the current ordinary-save boundary; it does not require
|
||||
reading and hashing the file for every keystroke or queued notification.
|
||||
|
||||
Content reuse within one operation is distinct from keeping a body cache across
|
||||
edits. A cached body would need a proven invalidation and freshness contract;
|
||||
mtime and size alone cannot establish that it is current. A saved provenance
|
||||
hash records evidence about earlier content, rather than replacing acquisition
|
||||
of the current bytes. Restart recovery, an incoming update, or a comparison
|
||||
which needs revalidation can still require additional reads. Preserve those
|
||||
checks rather than making a global promise of one read per path.
|
||||
|
||||
Batching therefore aims to reduce repeated content acquisition, comparison,
|
||||
Chunk processing, and local database updates, as well as intermediate revisions
|
||||
and subsequent replication and reflection work. Event filtering, bookkeeping,
|
||||
and pending-operation persistence still occur while waiting. The revision
|
||||
reduction measured by the existing E2E does not quantify file reads, CPU cost,
|
||||
physical disk I/O, or network traffic; measure those costs separately. Host or
|
||||
operating-system caching also means that a read API call is not a physical disk
|
||||
read count.
|
||||
|
||||
Manual flush and required deletion or rename ordering can end a batch early.
|
||||
Ordinary incoming replication continues, using the existing protection for
|
||||
unsaved local content. It must not require all unrelated local batches to flush
|
||||
for every incoming document.
|
||||
|
||||
Add an independent `batchSaveInLiveSync` Boolean, defaulting to `false`
|
||||
for both existing settings and new Vaults. In LiveSync mode, this value selects
|
||||
batching; other modes continue to use `batchSave`. Both choices use the existing
|
||||
minimum and maximum delay settings. The interface presents the choice for the
|
||||
current mode. An ordinary mode switch preserves each mode's choice.
|
||||
|
||||
This adds one persisted setting to distinguish a new explicit choice from old
|
||||
`liveSync: true, batchSave: true` configurations, where batching was ignored.
|
||||
Neither upgrade nor import of that legacy pair may activate batching. An older
|
||||
client continues to apply its existing LiveSync restriction. Include the new
|
||||
property in Commonlib's setting policy and import/export tests; do not infer
|
||||
consent from the presence of the old `batchSave` property.
|
||||
|
||||
The setting and scheduling changes follow the recovery work. Recovery in the
|
||||
currently supported modes must pass verification before the new mode is enabled.
|
||||
|
||||
### 6. Keep batching separate from automation completion
|
||||
|
||||
The intended benefit is fewer short-lived intermediate revisions. Batching
|
||||
cannot discover when a third-party template has finished, particularly while a
|
||||
dialogue is open or another device also runs automation. A maximum delay or a
|
||||
rename boundary can legitimately publish intermediate content.
|
||||
|
||||
Coalescing `CREATE` followed by `CHANGED` for the same still-existing file is a
|
||||
useful follow-up, but it needs its own ordering and interruption tests. Preserve
|
||||
the existing cross-path rename rule: store the destination before deleting the
|
||||
proven source branch. Do not retarget or discard pending source operations
|
||||
without proving the effect is already covered.
|
||||
|
||||
A guarantee that a multi-step template appears remotely only after completion
|
||||
would need an explicit producer completion contract and its failure behaviour.
|
||||
That is a separate design. Enabling batching alone must not be described as
|
||||
eliminating genuine concurrent-edit conflicts or as a confirmed fix for #1207.
|
||||
|
||||
## Ownership and implementation order
|
||||
|
||||
1. **Commonlib recovery:** event ownership, ordered snapshot persistence,
|
||||
successful completion and failure propagation, finite flush boundaries, and
|
||||
same-time content reconciliation. Add focused failing regressions before
|
||||
changing production code. Reuse the existing event manager, file handler,
|
||||
scanner, and provenance store.
|
||||
2. **Host integration:** keep the Obsidian persistence adapter and lifecycle
|
||||
wiring aligned with those contracts. Verify callers of
|
||||
`commitPendingFileEvents`, including settings application and database
|
||||
preparation, and preserve their failure outcomes. Validate the packed
|
||||
Commonlib artefact and update the consumer's exact dependency.
|
||||
3. **LiveSync mode batching:** add the explicit setting and its primary-language
|
||||
presentation only after the recovery regressions pass. Establish the stated
|
||||
timing semantics and repeat the two-device comparisons with the actual
|
||||
package, including start-up with batching already enabled.
|
||||
4. **Further coalescing:** assess same-path creation/editing and any proposed
|
||||
rename optimisation separately, against the original automation problem.
|
||||
|
||||
Keep these changes independently reviewable. Shared behaviour belongs in
|
||||
Commonlib; the plug-in must not acquire a source mirror or patch installed
|
||||
package files. Snapshot format changes, a second persistent store, or a new
|
||||
automation transaction API require a further design decision.
|
||||
|
||||
## Verification required
|
||||
|
||||
Use controlled promises at the manager and persistence boundaries for focused
|
||||
regressions, and real PouchDB tests for content and ancestry. The acceptance
|
||||
matrix is:
|
||||
|
||||
| Scenario | Required observation |
|
||||
| -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Stop after dequeue, timer release, or during a database write | Pending work remains recoverable; restart eventually represents the saved Vault edit. |
|
||||
| Database and provenance writes are complete before replay; restart twice | Replay adds no extra revision. |
|
||||
| Stop after the database write but before provenance; no remote descendant; replay twice | The tested replay creates an identical sibling and tombstones one of the two branches; one live leaf retains exact saved content, and the second restart adds no revision. |
|
||||
| A remote descendant arrives during the pre-provenance gap | The mergeable case preserves its exact content and ancestry; the overlapping case retains both bodies for manual resolution. |
|
||||
| Old and new events share a path; snapshot writes overlap | Completing old work cannot remove new work or regress the durable snapshot. |
|
||||
| Handler returns false, throws, or snapshot persistence fails | Flush reports failure; pending work is retained; unrelated recoverable work can continue. |
|
||||
| Buffered or in-progress work exists with no waiting timer | Flush still waits for actual completion. |
|
||||
| A flush overlaps new typing or a rename/delete barrier | The captured work completes without waiting for later work or deadlocking. |
|
||||
| Strict check: equal timestamps, unchanged or edited files, untracked or pruned provenance, unavailable Chunks, or concurrent recheck changes | Preserve correct ancestry and bytes, avoid duplicate revisions, and distinguish unresolved comparisons from successful reconciliation. |
|
||||
| Start-up check disabled: exact raw time, size, and provenance match with no conflict or pending processing | The body read may be skipped; an unrecorded edit which preserves exact size and modification time remains an accepted limitation. |
|
||||
| Pending-event replay and incoming or deletion guards, with either start-up setting | Keep replay and protection active; do not skip work or remove a file based only on matching statistics. |
|
||||
| Settings save followed by stop; ordinary unload; interruption during replay | No operation is forgotten because a settings callback or shutdown released its timer. |
|
||||
| Sustained edits and more paths than available processing slots | Observe the intentional-delay deadline and progress of unrelated ready files. |
|
||||
| Old settings, mode changes, explicit opt-in, and settings import | Existing LiveSync mode users retain immediate saving until they choose batching. |
|
||||
| Incoming identical content, an edit, a deletion, or delete/recreate while waiting | Preserve genuine branches without extra conflicts or loss of local work. |
|
||||
|
||||
Keep the existing E2E comparison between batching disabled, an ignored legacy
|
||||
setting, and explicitly enabled batching. Repeat both directions, manual
|
||||
synchronisation, saved-snapshot restart, and controlled in-progress restart with
|
||||
the fix. Record the original failure separately from repaired results; a pass
|
||||
with a different timestamp bucket or a test-only bundle is not verification of
|
||||
the production fix. Run platform-specific lifecycle checks before extending the
|
||||
evidence to Windows or mobile.
|
||||
|
||||
## Alternatives and consequences
|
||||
|
||||
- **Remove only the LiveSync guard:** leaves recovery gaps and can activate a
|
||||
previously ignored setting. The explicit choice follows correctness work.
|
||||
- **Wait only at unload:** cannot cover abrupt termination and hosts which do
|
||||
not await asynchronous unload. Continuous recoverability is still required.
|
||||
- **Repair only the scanner:** improves surviving-file recovery, but does not
|
||||
establish event ordering, deletion intent, or truthful flush completion.
|
||||
- **Retain only in-progress events:** closes a persistence gap, but still needs
|
||||
success/failure handling, replay checks, and recovery before the first snapshot.
|
||||
- **Remove timestamp rounding or force the Vault copy into the current winner:**
|
||||
fails for exactly equal timestamps and can destroy branch intent. Content and
|
||||
provenance determine the safe action.
|
||||
|
||||
Keeping work recoverable adds snapshot bookkeeping and can make local flush
|
||||
wait longer because it now waits for real processing. Same-time reconciliation
|
||||
can require additional content reads. Batching trades prompt remote visibility
|
||||
for fewer database updates and may increase the window for genuine concurrent
|
||||
edits. Measure these costs without weakening the content-preservation contract.
|
||||
|
||||
## References
|
||||
|
||||
- [Conflict resolution and revision provenance](../specs_conflict_resolution.md)
|
||||
- [Batch database update settings](../settings.md#batch-database-update)
|
||||
- [Common library package boundary](2026_07_common_library_package_boundary.md)
|
||||
- [Fast Fetch persistence and completion](2026_08_fast_fetch_persistence_and_completion.md)
|
||||
- [Two-device Batch save reproduction](../../test/e2e-obsidian/scripts/issue1207-livesync-batch.ts)
|
||||
- [Commonlib event manager at the investigated revision](https://github.com/vrtmrz/livesync-commonlib/blob/aa54282937c20d3db7f43778d240feeb1586cced/src/managers/StorageEventManager.ts)
|
||||
- [Commonlib Offline Scanner at the investigated revision](https://github.com/vrtmrz/livesync-commonlib/blob/aa54282937c20d3db7f43778d240feeb1586cced/src/serviceFeatures/offlineScanner.ts)
|
||||
- [Commonlib file handler at the investigated revision](https://github.com/vrtmrz/livesync-commonlib/blob/aa54282937c20d3db7f43778d240feeb1586cced/src/serviceModules/ServiceFileHandlerBase.ts)
|
||||
- [Commonlib scanner policy and stored event interpretation](https://github.com/vrtmrz/livesync-commonlib/blob/aa54282937c20d3db7f43778d240feeb1586cced/docs/offline-scanner.md)
|
||||
- [Commonlib event admission and reflection contract](https://github.com/vrtmrz/livesync-commonlib/blob/aa54282937c20d3db7f43778d240feeb1586cced/docs/storage-events-and-reflection.md)
|
||||
- [Existing equal-time provenance regression](https://github.com/vrtmrz/livesync-commonlib/blob/aa54282937c20d3db7f43778d240feeb1586cced/src/serviceFeatures/offlineScanner.provenance.unit.spec.ts)
|
||||
@@ -98,6 +98,10 @@ recovery guidance, or diagnostics intended for users.
|
||||
- **ID recovery code:** A versioned text form of the saved ID key which can be
|
||||
shown on the current device and imported without deriving a different key.
|
||||
Treat it as a secret.
|
||||
- **Joined devices:** The dialogue showing device records held by the active
|
||||
CouchDB or Object Storage remote, with the current device first. Records
|
||||
describe participation, not current online activity. **Forget device** removes
|
||||
an obsolete device record; it does not delete Vault files or revoke credentials.
|
||||
- **JWT Authentication:** An experimental CouchDB authentication option which
|
||||
uses a JSON Web Token instead of standard credentials. It requires a private
|
||||
key or secret, algorithm, expiry duration, subject, and key ID.
|
||||
|
||||
@@ -1,9 +1,70 @@
|
||||
# 1.0 preview release history
|
||||
|
||||
This document records the opt-in beta and release-candidate builds published before 1.0.0. Most users upgrading from 0.25.83 only need the consolidated [1.0.0 release notes](1.0.md#100).
|
||||
This document records opt-in pre-release builds in the 1.0 release line. Consolidated notes for stable upgrades are available in the [current change log](../../updates.md) and the [1.0 release history](1.0.md).
|
||||
|
||||
The prepared `1.0.0-rc.0` tag was not published as a plug-in release and is therefore omitted.
|
||||
|
||||
Version 1.0.41 was published for validation and remains a GitHub pre-release. Its changes and the subsequent start-up correction are consolidated in the [1.0.42 release notes](../../updates.md#1042).
|
||||
|
||||
## 1.0.41
|
||||
|
||||
26th October, 2026
|
||||
|
||||
### Synchronisation
|
||||
|
||||
#### New Feature
|
||||
|
||||
- Rapid edits can produce fewer intermediate versions on other devices while LiveSync mode remains active.
|
||||
- Enable **Batch database updates in LiveSync mode** to group successive local edits.
|
||||
- Disabled by default; each synchronisation mode keeps its own choice.
|
||||
- Updates reach other devices later. Batching does not detect template completion or prevent genuine concurrent-edit conflicts.
|
||||
- CouchDB synchronisation no longer looks idle while it is comparing change history.
|
||||
- A ⏩ icon and comparison count show activity before documents are transferred.
|
||||
- This count represents history entries, not files remaining to synchronise.
|
||||
- Status counts are easier to follow, with less movement as values change.
|
||||
- CSS snippets can hide unwanted items or style individual `livesync-stat-` items, including inactive ones.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Recent Vault edits remain recoverable after an interrupted local database save.
|
||||
- This includes edits whose modification times compare equal to the database copy, and pending Hidden File Sync and Customisation Sync changes.
|
||||
- Manual synchronisation no longer starts before already accepted local changes finish saving to the database.
|
||||
- Settings application also waits for those saves. Failed saves stay pending and report a failure.
|
||||
- Text from third-party tools is handled more robustly.
|
||||
- Characters split across Chunks no longer trigger false corruption warnings.
|
||||
- Start-up integrity checks use less memory for healthy Vault files. (#1283)
|
||||
- Start-up checks avoid false repair warnings for files that change during inspection. (#1283)
|
||||
- This applies when the final size matches the database and there are no conflicts.
|
||||
|
||||
### Customisation Sync
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Applying a multi-file customisation no longer skips files after the list is refreshed. (#1271)
|
||||
- Choosing **Automatic** now explains a missing Hidden File Sync prerequisite instead of proceeding into a failed initialisation. (#1270)
|
||||
- Enable and initialise Hidden File Sync on this device first. The current mode is kept when the prerequisite is missing.
|
||||
- Initialising a whole plug-in in **Automatic** mode no longer omits its selected **MAIN** or **DATA** files.
|
||||
|
||||
### Setup tools
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Setup URI generation can avoid shell-variable collisions and unintended use of the operating-system username. (#817, #1274)
|
||||
- Use `LIVESYNC_HOST`, `LIVESYNC_USER`, `LIVESYNC_PASS`, `LIVESYNC_DB`, and `LIVESYNC_PASSPHRASE` for connection and encryption values.
|
||||
- Existing scripts remain supported: the previous names are used when the corresponding prefixed value is unset or blank.
|
||||
|
||||
### Miscellaneous
|
||||
|
||||
- Dependencies have been updated, including security fixes.
|
||||
|
||||
### Acknowledgements
|
||||
|
||||
Thank you for your contributions!
|
||||
|
||||
- @rwbru, for the original Setup URI environment-variable fix ([#817](https://github.com/vrtmrz/obsidian-livesync/pull/817)).
|
||||
- @junkerderprovinz, for adapting that fix to the current generator while preserving compatibility with existing scripts ([#1274](https://github.com/vrtmrz/obsidian-livesync/pull/1274)).
|
||||
- @s-qin, for reducing memory use during start-up checks and avoiding repeated content decoding in Commonlib ([#1283](https://github.com/vrtmrz/obsidian-livesync/pull/1283), [Commonlib #184](https://github.com/vrtmrz/livesync-commonlib/pull/184)).
|
||||
|
||||
## 1.0.0-rc.1
|
||||
|
||||
27th July, 2026
|
||||
|
||||
@@ -2,6 +2,30 @@
|
||||
|
||||
This document contains earlier published releases from the 1.0 line of the [current Self-hosted LiveSync release history](../../updates.md). Beta and release-candidate builds published before 1.0.0 are recorded in the [1.0 preview history](1.0-previews.md). Earlier release lines continue in the [0.25 history](0.25.md) and the [legacy history](legacy.md).
|
||||
|
||||
## 1.0.35
|
||||
|
||||
5th October, 2026
|
||||
|
||||
### Synchronisation
|
||||
|
||||
#### Fixed
|
||||
|
||||
- We can continue synchronising after rebuilding an encrypted CouchDB remote, including when Hidden File Sync and Customisation Sync are enabled. (#1234)
|
||||
- This plug-in reads the rebuilt data without reusing HTTP responses encrypted with the previous Security Seed.
|
||||
|
||||
### Customisation Sync
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Customisation Sync now waits until this plug-in is ready before handling configuration file events, and applying settings while synchronisation is suspended no longer scans configuration files. (#1220)
|
||||
- Resuming synchronisation scans once and restarts the periodic scan when enabled.
|
||||
|
||||
### Acknowledgements
|
||||
|
||||
Thank you for your contributions!
|
||||
|
||||
- @kimjansheden (#1220)
|
||||
|
||||
## 1.0.34
|
||||
|
||||
2nd October, 2026
|
||||
|
||||
+60
-8
@@ -248,6 +248,16 @@ Setting key: remoteType
|
||||
|
||||
The active connection type. This is automatically projected to the legacy configuration when you activate a connection profile.
|
||||
|
||||
### Joined devices
|
||||
|
||||
Choose **Review devices** under **Joined devices** to read the device records on the active CouchDB or Object Storage connection. The same **Review devices** action is available from the command palette and version Notices. **Refresh** reads the remote again. This device appears first, including when its record is absent from the remote. Opening or refreshing this dialogue does not create a missing CouchDB database.
|
||||
|
||||
The list shows each device ID, recorded platform, plug-in version, app version, and last recorded connection time. Older clients may have incomplete information. These records do not establish whether a device is online. On a locked remote, a device outside the accepted list is shown as **Locked out**.
|
||||
|
||||
Before synchronisation starts, and when this dialogue refreshes, Self-hosted LiveSync checks for recorded plug-in versions newer or older than its own. These Notices recommend reviewing and aligning plug-in versions across devices; they are advisory and do not stop synchronisation. The dialogue explains that synchronisation between different versions is generally safe unless the connection is explicitly blocked, but may be less efficient or use more storage. P2P synchronisation may also be unavailable. Unknown development version labels are left unclassified. Automatic checks run at most once per minute when synchronisation starts; they do not poll a continuously running connection.
|
||||
|
||||
**Forget device** removes another device's participation records after confirmation. It preserves the remote lock state and every other device. It does not delete Vault files or revoke credentials. A device can register again on an unlocked remote; on a locked remote, the existing recovery process applies. The result is checked after saving to CouchDB; if it cannot be confirmed, refresh the list and try again. CouchDB stores these records together in a local document which does not reject simultaneous updates, so changes from other devices can be overwritten. Pausing synchronisation on the other devices avoids that overlap. Object Storage uses conditional updates; if the server cannot support them, the record is retained.
|
||||
|
||||
### 2. Notification
|
||||
|
||||
#### Notify when the estimated remote storage size exceeds on start up
|
||||
@@ -673,17 +683,28 @@ Setup URIs preserve both sleep preferences. Older Setup URIs which do not contai
|
||||
#### Batch database update
|
||||
|
||||
Setting key: batchSave
|
||||
Reducing the frequency with which on-disk changes are reflected into the DB
|
||||
|
||||
Combine repeated local file updates before saving them to the local database in modes other than LiveSync mode. This reduces intermediate database revisions and delays their availability to other devices.
|
||||
|
||||
#### Batch database updates in LiveSync mode
|
||||
|
||||
Setting key: batchSaveInLiveSync
|
||||
|
||||
Apply the same bounded delay in LiveSync mode. This separate choice is disabled by default for both existing settings and new Vaults. An older configuration containing `liveSync: true` and `batchSave: true` does not enable it. The settings interface shows the choice for the current mode, and switching modes preserves both choices. Older clients continue to ignore batching in LiveSync mode.
|
||||
|
||||
Both choices share the delays below. Manual synchronisation, deletion, or renaming can end a batch early. Batching reduces intermediate updates; it does not identify when a template has finished or prevent genuine concurrent edits on different devices.
|
||||
|
||||
#### Minimum delay for batch database updating
|
||||
|
||||
Setting key: batchSaveMinimumDelay
|
||||
Seconds. Saving to the local database will be delayed until this value after we stop typing or saving.
|
||||
|
||||
Seconds of quiet after the latest eligible edit before saving to the local database, subject to the maximum delay below.
|
||||
|
||||
#### Maximum delay for batch database updating
|
||||
|
||||
Setting key: batchSaveMaximumDelay
|
||||
Saving will be performed forcefully after this number of seconds.
|
||||
|
||||
Maximum intentional delay in seconds from the first pending update to a file. Later edits do not restart this limit, and waiting for a processing slot does not add another batch delay. This limit makes the work eligible to run; it does not guarantee database completion or receipt by another device within that time.
|
||||
|
||||
### 4. Deletion Propagation (Advanced)
|
||||
|
||||
@@ -764,11 +785,13 @@ If enabled, the notification of hidden files change will be suppressed.
|
||||
|
||||
#### Synchronising files
|
||||
|
||||
(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.
|
||||
Setting key: syncOnlyRegEx
|
||||
(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.
|
||||
|
||||
#### Non-Synchronising files
|
||||
|
||||
(RegExp) If this is set, any changes to local and remote files that match this will be skipped.
|
||||
Setting key: syncIgnoreRegEx
|
||||
(RegExp) Skip changes to matching normal and hidden files, locally and remotely.
|
||||
|
||||
#### Maximum file size
|
||||
|
||||
@@ -797,6 +820,8 @@ When a saved setting changes whether a normal file can be reflected, LiveSync re
|
||||
|
||||
Customisation Sync is a supported, advanced opt-in feature. Its current per-file implementation is covered by a two-Vault real-Obsidian workflow for snippets, configuration files, and plug-in files. Hidden File Sync is a separate feature with different setup, selection, and conflict behaviour; do not use both features to manage the same files.
|
||||
|
||||
**Automatic** delegates an item's files to Hidden File Sync. Enable and initialise Hidden File Sync in **Sync Settings** on this device first, after reviewing its [target and ignore patterns](./tips/hidden-file-sync.md#review-the-file-selection). Choosing Automatic while Hidden File Sync is disabled displays a Notice and leaves the current mode unchanged. Existing Automatic assignments, including those imported from another device, remain saved while Hidden File Sync is disabled.
|
||||
|
||||
### 1. Customisation Sync
|
||||
|
||||
#### Device name
|
||||
@@ -950,6 +975,18 @@ Setting key: hashCacheMaxAmount
|
||||
|
||||
Setting key: customChunkSize
|
||||
|
||||
New setup uses 0 for every remote type. With V3 Rabin–Karp, this gives binary
|
||||
Chunks a 100 KiB maximum, a 25 KiB minimum, and an approximate 25 KiB candidate
|
||||
gap. The candidate gap is not the mean Chunk length. Text below 4 MiB retains
|
||||
adaptive smaller boundaries; large text uses the binary thresholds with
|
||||
UTF-8-safe cuts.
|
||||
|
||||
Larger values can reduce document counts for mostly immutable binary files,
|
||||
but can increase the new content stored after an edit. Existing explicit and
|
||||
agreed values are retained. Updating the plug-in does not re-chunk the Vault;
|
||||
editing, forced writes, Chunk recreation, or a full Rebuild can create new
|
||||
boundaries while existing content remains readable.
|
||||
|
||||
#### Chunk Splitter
|
||||
|
||||
Setting key: chunkSplitterVersion
|
||||
@@ -966,12 +1003,21 @@ If enabled, chunks will be split into no more than 100 items. However, dedupe is
|
||||
Setting key: useSegmenter
|
||||
If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.
|
||||
|
||||
#### Strict content check on startup
|
||||
|
||||
Setting key: strictContentCheckOnStartup
|
||||
|
||||
When enabled, ordinary start-up compares file content with the local database even when the file's exact modification time and size match. When disabled (the default), this comparison can be skipped only when the modification time, size, and recorded device-local provenance match a conflict-free current revision, and no file processing is pending. An edit made outside the normal file-event path which preserves its exact modification time and size can therefore go undetected by ordinary start-up. Manual scans and explicit Fetch or Rebuild operations keep strict content checking.
|
||||
|
||||
### 3. Transfer Tweak
|
||||
|
||||
#### Fetch chunks on demand
|
||||
|
||||
Setting key: readChunksOnline
|
||||
(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended.
|
||||
If enabled, this plug-in obtains missing Chunks when they are needed instead
|
||||
of including all Chunks in ordinary pull replication. New setup retains the
|
||||
same Chunk-size recommendation of 0. This setting does not re-chunk content
|
||||
already stored by another device.
|
||||
|
||||
#### Batch size of on-demand fetching
|
||||
|
||||
@@ -1024,12 +1070,18 @@ The setting remains disabled by default because the measured storage and transfe
|
||||
#### Batch size
|
||||
|
||||
Setting key: batch_size
|
||||
Number of changes to sync at a time. Defaults to 50. Minimum is 2.
|
||||
Number of changes in an ordinary CouchDB replication batch. The schema default
|
||||
is 25, and the minimum is 2. Changing this value alone preserves the
|
||||
replication checkpoint. Larger counts can reduce round trips, but do not bound
|
||||
the final request size or memory use.
|
||||
|
||||
#### Batch limit
|
||||
|
||||
Setting key: batches_limit
|
||||
Number of batches to process at a time. Defaults to 40. Minimum is 2. This along with batch size controls how many docs are kept in memory at a time.
|
||||
Number of changes batches which PouchDB can read ahead. The schema default is
|
||||
25, and the minimum is 2. This does not create parallel body transfers;
|
||||
PouchDB processes one current replication batch at a time. It does not control
|
||||
the separate P2P or Object Storage transfer units.
|
||||
|
||||
#### Use timeouts instead of heartbeats
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export secret_key=<SECRET KEY>
|
||||
export bucket=vault-data
|
||||
export region=auto
|
||||
export bucket_prefix=my-vault
|
||||
export passphrase=<A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
export LIVESYNC_PASSPHRASE=<A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
export uri_passphrase=<A SEPARATE SETUP URI PASSPHRASE>
|
||||
deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts
|
||||
```
|
||||
|
||||
@@ -128,6 +128,9 @@ export database=obsidiannotes
|
||||
curl -s https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh | bash
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> In fish, `hostname` is read-only and cannot be exported. Pass the values through `env` instead: `curl -s https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh | env hostname=http://localhost:5984 username=<...> password=<...> database=obsidiannotes bash`.
|
||||
|
||||
If it results like the following:
|
||||
```
|
||||
CouchDB provisioning completed.
|
||||
@@ -173,20 +176,20 @@ Now `https://tiles-photograph-routine-groundwater.trycloudflare.com` is our serv
|
||||
|
||||
### 1. Generate the setup URI on a desktop device or server
|
||||
|
||||
The `username` and `password` here are the credentials which Self-hosted LiveSync will store for routine access to this database. They may be the administrator credentials from step 2. If you have separately configured a CouchDB account with the required access to this database, use that account instead. Neither the provisioning wrapper nor the Setup URI generator creates that separate account.
|
||||
The `LIVESYNC_USER` and `LIVESYNC_PASS` here are the credentials which Self-hosted LiveSync will store for routine access to this database. They may be the administrator credentials from step 2. If you have separately configured a CouchDB account with the required access to this database, use that account instead. Neither the provisioning wrapper nor the Setup URI generator creates that separate account.
|
||||
|
||||
```bash
|
||||
export hostname=https://tiles-photograph-routine-groundwater.trycloudflare.com
|
||||
export database=obsidiannotes
|
||||
export username=<INSERT COUCHDB USERNAME FOR LIVESYNC>
|
||||
export password=<INSERT THE COUCHDB PASSWORD>
|
||||
export passphrase=<INSERT A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
export LIVESYNC_HOST=https://tiles-photograph-routine-groundwater.trycloudflare.com
|
||||
export LIVESYNC_DB=obsidiannotes
|
||||
export LIVESYNC_USER=<INSERT COUCHDB USERNAME FOR LIVESYNC>
|
||||
export LIVESYNC_PASS=<INSERT THE COUCHDB PASSWORD>
|
||||
export LIVESYNC_PASSPHRASE=<INSERT A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
export uri_passphrase=<INSERT A SEPARATE SETUP URI PASSPHRASE> # Optional
|
||||
deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> `passphrase` protects the synchronised Vault data with end-to-end encryption. `uri_passphrase` protects only the Setup URI. Use different values, store both securely, and do not send the Setup URI and its passphrase through the same channel.
|
||||
> `LIVESYNC_PASSPHRASE` protects the synchronised Vault data with end-to-end encryption. `uri_passphrase` protects only the Setup URI. Use different values, store both securely, and do not send the Setup URI and its passphrase through the same channel.
|
||||
>
|
||||
> If `uri_passphrase` is omitted, the generator creates a cryptographically random value and prints it once.
|
||||
|
||||
|
||||
+1
-1
@@ -130,7 +130,7 @@ export remote_type=p2p
|
||||
export p2p_relays=wss://relay.example.com
|
||||
export p2p_room_id=<A PRIVATE ROOM ID> # Optional; generated when omitted
|
||||
export p2p_passphrase=<A PRIVATE P2P PASSPHRASE> # Optional; generated when omitted
|
||||
export passphrase=<A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
export LIVESYNC_PASSPHRASE=<A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
export uri_passphrase=<A SEPARATE SETUP URI PASSPHRASE>
|
||||
deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts
|
||||
```
|
||||
|
||||
@@ -166,6 +166,49 @@ For ordinary saves and incoming reflection, a missing or unreadable recorded bas
|
||||
|
||||
Provenance keys follow the selected [Unicode path identity](design_docs/unicode_path_identity.md#state-which-follows-the-selected-identity). In NFC mode, equivalent path spellings address the same record; the content evidence continues to describe file bytes, independently of that path spelling.
|
||||
|
||||
## Known potentially problematic behaviour
|
||||
|
||||
Saving a local file writes its revision to the local database, then writes
|
||||
device-local provenance in a separate operation. The writes are not atomic.
|
||||
If the process stops between them, provenance can still identify the revision
|
||||
which preceded the local edit. Continuous replication which was already
|
||||
running can add a remote descendant to the local database during this
|
||||
interval. The per-file lock covers file handling and Vault reflection; it
|
||||
does not block raw replication writes to the database.
|
||||
|
||||
For example, local content `2-B` may have been saved as a child of
|
||||
displayed revision `1`, while provenance still identifies `1`.
|
||||
On restart, replay of the pending save can treat `2-B` as an edit of
|
||||
`1` and write sibling `2-D`. The tested case demonstrates that this
|
||||
pre-provenance gap alone can produce the duplicate. Without a remote
|
||||
descendant, replay creates this sibling; duplicate handling tombstones one
|
||||
of the identical branches and converges to one live leaf whose exact content
|
||||
matches the saved local file. A second restart must add no revision.
|
||||
|
||||
If replication has already added descendant `3-C` during the gap, replay
|
||||
can expose an additional branch. When the revisions have a complete shared
|
||||
base and their text changes can be combined unambiguously, sensible merge can retain the
|
||||
exact content of the incoming descendant. In the tested fixture, merge
|
||||
produced revision `4` as a child of `3-C`; its file bytes were exactly
|
||||
those of `3-C`, `3-C` remained in its ancestry, and sibling
|
||||
`2-D` was tombstoned. The acceptance condition is retaining
|
||||
`3-C`'s exact content; it need not remain the current leaf. Both Vaults
|
||||
converged to those bytes. This observed outcome is limited to that mergeable
|
||||
fixture and is not a universal no-data-loss guarantee.
|
||||
|
||||
When changes cannot be combined safely, as in the tested competing whole-line
|
||||
replacements, sensible merge does not combine the bodies. Both versions remain available for the existing
|
||||
manual conflict dialogue, even when sensible merge is enabled.
|
||||
|
||||
An interruption in the database/provenance interval is expected to be very
|
||||
rare, especially when combined with a remote descendant arriving in that
|
||||
interval; the field frequency is unmeasured. Start-up replays restored work,
|
||||
scans, and commits pending local file work before marking the plug-in ready;
|
||||
ordinary replication proceeds after that ready boundary. This does not claim
|
||||
remote synchronisation before start-up readiness. The accepted design adds no
|
||||
cross-store transaction, second journal, or new persistent state to close the
|
||||
interval.
|
||||
|
||||
## Operations while a conflict exists
|
||||
|
||||
- Editing a file writes a child of its recorded or uniquely reconstructed displayed revision.
|
||||
|
||||
@@ -32,6 +32,10 @@ Enabling Hidden File Sync requires an initialisation direction:
|
||||
|
||||
Prefer a narrow target list. Device-specific workspace state and another plug-in's credentials are usually poor candidates for cross-device synchronisation.
|
||||
|
||||
`Normal Files` → `Synchronising files` limits ordinary files only. Hidden File Sync uses its own `Target patterns`. The `Non-Synchronising files` patterns under `Normal Files` also exclude matching hidden files, so review them alongside the hidden-file selectors.
|
||||
|
||||
If an earlier version skipped hidden files because of the normal-file allow-list, repeat Hidden File Sync initialisation after updating. A routine scan can skip unchanged files which were already checked. Back up the Vault and choose the intended source direction as described above.
|
||||
|
||||
Target patterns also control directory traversal. A pattern must therefore match each parent directory as well as the intended files. For example, the following pattern admits the `.obsidian` parent and only its `snippets` subtree:
|
||||
|
||||
```text
|
||||
@@ -68,6 +72,12 @@ Hidden File Sync must be enabled independently after ordinary setup on each devi
|
||||
|
||||
If you later run Rebuild or Fetch as a recovery operation, expect optional features to be disabled again. Complete ordinary recovery first, then repeat this guide deliberately.
|
||||
|
||||
## Automatic in Customisation Sync
|
||||
|
||||
**Automatic** delegates an item's files to Hidden File Sync. Enable Hidden File Sync on this device through the steps above before choosing Automatic. If Hidden File Sync is disabled, the choice displays a Notice without changing the item's mode or starting initialisation. Previously saved Automatic assignments remain saved, so this prerequisite also applies after importing settings from another device.
|
||||
|
||||
Once Hidden File Sync is enabled, choose the item's initial action in Customisation Sync. Selecting a whole plug-in applies that action to both its **MAIN** and **DATA** selections.
|
||||
|
||||
## Related settings
|
||||
|
||||
The [settings reference](../settings.md#7-hidden-files-advanced) describes periodic scanning, scanning before replication, notification suppression, selectors, and overwrite patterns. Change those controls only after the basic Hidden File Sync path is working.
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "1.0.39",
|
||||
"version": "1.0.42",
|
||||
"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",
|
||||
|
||||
Generated
+204
-233
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.39",
|
||||
"version": "1.0.42",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.39",
|
||||
"version": "1.0.42",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"src/apps/cli",
|
||||
@@ -23,12 +23,13 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.44",
|
||||
"@vrtmrz/livesync-commonlib": "0.2.2",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"@vrtmrz/value-store": "0.1.0",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"idb": "^8.0.3",
|
||||
"markdown-it": "^14.3.2",
|
||||
"markdown-it": "^14.3.3",
|
||||
"minimatch": "^10.2.5",
|
||||
"obsidian": "^1.13.1",
|
||||
"octagonal-wheels": "^0.1.55",
|
||||
@@ -54,7 +55,7 @@
|
||||
"@types/pouchdb-replication": "^6.4.7",
|
||||
"@types/transform-pouch": "^1.0.6",
|
||||
"@typescript-eslint/parser": "8.69.0",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"@vitest/coverage-v8": "^4.1.12",
|
||||
"@vrtmrz/obsidian-test-session": "0.2.6",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"esbuild": "0.28.1",
|
||||
@@ -89,8 +90,8 @@
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.69.0",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.11",
|
||||
"vite": "^8.1.6",
|
||||
"vitest": "^4.1.12",
|
||||
"yaml": "^2.8.2"
|
||||
}
|
||||
},
|
||||
@@ -1944,22 +1945,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.6.tgz",
|
||||
"integrity": "sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.2"
|
||||
"@tybys/wasm-util": "^0.10.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=23.5.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
"@emnapi/core": "^1.7.1 || 2.0.0-beta.2",
|
||||
"@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
@@ -2011,9 +2015,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.133.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
||||
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
|
||||
"version": "0.139.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -2221,9 +2225,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2238,9 +2242,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2255,9 +2259,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2272,9 +2276,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2289,9 +2293,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
|
||||
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
|
||||
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2306,13 +2310,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2323,13 +2330,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2340,13 +2350,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2357,13 +2370,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2374,13 +2390,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2391,13 +2410,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2408,9 +2430,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2425,9 +2447,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
|
||||
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
|
||||
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -2435,52 +2457,18 @@
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.10.0",
|
||||
"@emnapi/runtime": "1.10.0",
|
||||
"@napi-rs/wasm-runtime": "^1.1.4"
|
||||
"@emnapi/core": "1.11.1",
|
||||
"@emnapi/runtime": "1.11.1",
|
||||
"@napi-rs/wasm-runtime": "^1.1.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2495,9 +2483,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3346,9 +3334,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||
"version": "0.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
|
||||
"integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -3962,14 +3950,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
|
||||
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.12.tgz",
|
||||
"integrity": "sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
@@ -3983,8 +3971,8 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.11",
|
||||
"vitest": "4.1.11"
|
||||
"@vitest/browser": "4.1.12",
|
||||
"vitest": "4.1.12"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
@@ -3993,16 +3981,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
|
||||
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
|
||||
"integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -4011,13 +3999,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
|
||||
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
|
||||
"integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -4038,9 +4026,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
|
||||
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
|
||||
"integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4051,13 +4039,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
|
||||
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
|
||||
"integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -4065,14 +4053,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
|
||||
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
|
||||
"integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -4081,9 +4069,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
|
||||
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
|
||||
"integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -4091,13 +4079,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
|
||||
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
|
||||
"integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -4115,9 +4103,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.1.44",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.44.tgz",
|
||||
"integrity": "sha512-GjXNSLTnWLZzdgrhW3sIOsLqGLP4iMgypPMtcBc2Ju2vMq64TRA1Llfoya1KRIeu6m0G7Xu3ZtkWARTRcHkYYg==",
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.2.2.tgz",
|
||||
"integrity": "sha512-+nl4ntmRtUmSUuh9mOpicE0U/wpzhSPSQQXy9eg/2xdTBL58jkrQ6bQjbVKEhqUj0bUvg0ESjZm8YX/x+4zXiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-crypto/sha256-js": "5.2.0",
|
||||
@@ -4128,20 +4116,23 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@trystero-p2p/nostr": "0.25.3",
|
||||
"@vrtmrz/value-store": "0.1.0",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"events": "^3.3.0",
|
||||
"fflate": "^0.8.2",
|
||||
"idb": "^8.0.3",
|
||||
"markdown-it": "^14.3.2",
|
||||
"markdown-it": "^14.3.3",
|
||||
"minimatch": "^10.2.6",
|
||||
"octagonal-wheels": "^0.1.55",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
"pouchdb-adapter-idb": "^9.0.0",
|
||||
"pouchdb-adapter-indexeddb": "^9.0.0",
|
||||
"pouchdb-adapter-memory": "^9.0.0",
|
||||
"pouchdb-checkpointer": "9.0.0",
|
||||
"pouchdb-core": "^9.0.0",
|
||||
"pouchdb-errors": "^9.0.0",
|
||||
"pouchdb-find": "^9.0.0",
|
||||
"pouchdb-generate-replication-id": "9.0.0",
|
||||
"pouchdb-mapreduce": "^9.0.0",
|
||||
"pouchdb-merge": "^9.0.0",
|
||||
"pouchdb-replication": "^9.0.0",
|
||||
@@ -4210,6 +4201,12 @@
|
||||
"integrity": "sha512-njT2BSFh57imwGDxWXJOcGbZOYFVNGmeXMrS7/RUdBoAeOjWzJB9TxI7WcR1sgQIXKh8TE9hqALU8lue5b8dRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vrtmrz/value-store": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/value-store/-/value-store-0.1.0.tgz",
|
||||
"integrity": "sha512-M62f1j0bnJkqpgI5sYxnkXUAOSDEwQbYrLf+w58gCCm4sjIXDmFzDJd2duXabRbktBlACt9P99yjN/9gOAPxrA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/abstract-leveldown": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz",
|
||||
@@ -6367,9 +6364,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"version": "3.1.9",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
|
||||
"integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -8253,9 +8250,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.3.2",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz",
|
||||
"integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==",
|
||||
"version": "14.3.3",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz",
|
||||
"integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -8804,6 +8801,19 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pkg-dir": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
|
||||
@@ -9576,13 +9586,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
||||
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.133.0",
|
||||
"@oxc-project/types": "=0.139.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -9592,21 +9602,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.3",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.3",
|
||||
"@rolldown/binding-darwin-x64": "1.0.3",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.3",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.3",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.3",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.3",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.3",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.3",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.3"
|
||||
"@rolldown/binding-android-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-x64": "1.1.5",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.5",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.5",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.5",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.5",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.5",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/sade": {
|
||||
@@ -10455,19 +10465,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
@@ -10837,16 +10834,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.16",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||
"version": "8.1.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.6.tgz",
|
||||
"integrity": "sha512-aRAHIxx5SW5zc1sMqFwJU/t5qaFfXb5Hj0J7W0VXihu/X99+346tz6080tO1T1xVIJORX/b3zVTXGFqUXh35uw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.15",
|
||||
"rolldown": "1.0.3",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.17",
|
||||
"rolldown": "~1.1.5",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -10863,7 +10860,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.1.18",
|
||||
"@vitejs/devtools": "^0.3.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
@@ -10929,19 +10926,6 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vitefu": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz",
|
||||
@@ -10963,19 +10947,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
|
||||
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
|
||||
"integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.11",
|
||||
"@vitest/mocker": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/runner": "4.1.11",
|
||||
"@vitest/snapshot": "4.1.11",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/expect": "4.1.12",
|
||||
"@vitest/mocker": "4.1.12",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/runner": "4.1.12",
|
||||
"@vitest/snapshot": "4.1.12",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -11003,12 +10987,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.11",
|
||||
"@vitest/browser-preview": "4.1.11",
|
||||
"@vitest/browser-webdriverio": "4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.11",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"@vitest/ui": "4.1.11",
|
||||
"@vitest/browser-playwright": "4.1.12",
|
||||
"@vitest/browser-preview": "4.1.12",
|
||||
"@vitest/browser-webdriverio": "^4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.12",
|
||||
"@vitest/coverage-v8": "4.1.12",
|
||||
"@vitest/ui": "4.1.12",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
@@ -11059,19 +11043,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vitest/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vuvuzela": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vuvuzela/-/vuvuzela-1.0.3.tgz",
|
||||
@@ -11398,7 +11369,7 @@
|
||||
},
|
||||
"src/apps/cli": {
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"version": "1.0.39-cli",
|
||||
"version": "1.0.42-cli",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
@@ -11417,13 +11388,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.11"
|
||||
"vite": "^8.1.6",
|
||||
"vitest": "^4.1.12"
|
||||
}
|
||||
},
|
||||
"src/apps/webapp": {
|
||||
"name": "livesync-webapp",
|
||||
"version": "1.0.39-webapp",
|
||||
"version": "1.0.42-webapp",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.55"
|
||||
},
|
||||
@@ -11431,11 +11402,11 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"svelte": "5.56.3",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.1.6"
|
||||
}
|
||||
},
|
||||
"src/apps/webpeer": {
|
||||
"version": "1.0.39-webpeer",
|
||||
"version": "1.0.42-webpeer",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.55"
|
||||
},
|
||||
@@ -11446,7 +11417,7 @@
|
||||
"svelte": "5.56.3",
|
||||
"svelte-check": "^4.6.0",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.1.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.39",
|
||||
"version": "1.0.42",
|
||||
"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",
|
||||
@@ -57,6 +57,8 @@
|
||||
"test:e2e:obsidian:smoke": "tsx test/e2e-obsidian/scripts/smoke.ts",
|
||||
"test:e2e:obsidian:onboarding-invitation": "tsx test/e2e-obsidian/scripts/onboarding-invitation.ts",
|
||||
"test:e2e:obsidian:dialog-mounts": "tsx test/e2e-obsidian/scripts/dialog-mounts.ts",
|
||||
"test:e2e:obsidian:joined-devices": "tsx test/e2e-obsidian/scripts/joined-devices.ts",
|
||||
"test:e2e:obsidian:joined-devices-object-storage": "tsx test/e2e-obsidian/scripts/joined-devices.ts --object-storage",
|
||||
"test:e2e:obsidian:database-diagnostics": "tsx test/e2e-obsidian/scripts/database-diagnostics.ts",
|
||||
"test:e2e:obsidian:database-diagnostics-couchdb": "tsx test/e2e-obsidian/scripts/database-diagnostics-couchdb.ts",
|
||||
"test:e2e:obsidian:conflict-dialog-policy": "tsx test/e2e-obsidian/scripts/conflict-dialog-policy.ts",
|
||||
@@ -68,6 +70,7 @@
|
||||
"test:e2e:obsidian:configuration-encryption-sync": "tsx test/e2e-obsidian/scripts/configuration-encryption-sync.ts",
|
||||
"test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts",
|
||||
"test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts",
|
||||
"test:e2e:obsidian:replication-status": "tsx test/e2e-obsidian/scripts/replication-status.ts",
|
||||
"test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts",
|
||||
"test:e2e:obsidian:couchdb-upload": "tsx test/e2e-obsidian/scripts/couchdb-upload.ts",
|
||||
"test:e2e:obsidian:couchdb-http-cache": "tsx test/e2e-obsidian/scripts/couchdb-http-cache.ts",
|
||||
@@ -85,6 +88,7 @@
|
||||
"test:e2e:obsidian:p2p-connection-check": "tsx test/e2e-obsidian/scripts/p2p-connection-check.ts",
|
||||
"test:e2e:obsidian:p2p-connection-check:services": "npm run test:e2e:obsidian:p2p-connection-check -- --manage-p2p",
|
||||
"test:e2e:obsidian:partial-startup-file-failure": "tsx test/e2e-obsidian/scripts/partial-startup-file-failure.ts",
|
||||
"test:e2e:obsidian:startup-content-check": "tsx test/e2e-obsidian/scripts/startup-content-check.ts",
|
||||
"test:e2e:obsidian:startup-scan": "tsx test/e2e-obsidian/scripts/startup-scan.ts",
|
||||
"test:e2e:obsidian:stale-file-restart": "tsx test/e2e-obsidian/scripts/stale-file-restart.ts",
|
||||
"test:e2e:obsidian:folder-batch": "tsx test/e2e-obsidian/scripts/folder-batch.ts",
|
||||
@@ -92,7 +96,11 @@
|
||||
"test:e2e:obsidian:two-vault-sync": "tsx test/e2e-obsidian/scripts/two-vault-sync.ts",
|
||||
"test:e2e:obsidian:security-seed-reconnect": "tsx test/e2e-obsidian/scripts/security-seed-reconnect.ts",
|
||||
"test:e2e:obsidian:hidden-file-snippet-sync": "tsx test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts",
|
||||
"test:e2e:obsidian:hidden-file-selectors": "tsx test/e2e-obsidian/scripts/hidden-file-selectors.ts",
|
||||
"test:e2e:obsidian:customisation-sync": "tsx test/e2e-obsidian/scripts/customisation-sync.ts",
|
||||
"test:e2e:obsidian:customisation-catalogue-reload": "tsx test/e2e-obsidian/scripts/customisation-catalogue-reload.ts",
|
||||
"test:e2e:obsidian:optional-file-save-completion": "tsx test/e2e-obsidian/scripts/optional-file-save-completion.ts",
|
||||
"test:e2e:obsidian:livesync-batch": "tsx test/e2e-obsidian/scripts/issue1207-livesync-batch.ts",
|
||||
"test:e2e:obsidian:received-change-readiness": "tsx test/e2e-obsidian/scripts/received-change-readiness.ts",
|
||||
"test:e2e:obsidian:remote-feature-change": "tsx test/e2e-obsidian/scripts/remote-feature-change.ts",
|
||||
"test:e2e:obsidian:internal-metadata-migration": "tsx test/e2e-obsidian/scripts/internal-metadata-migration.ts",
|
||||
@@ -120,7 +128,10 @@
|
||||
"test:docker-all:start": "npm run test:docker-all:up && sleep 5 && npm run test:docker-all:init",
|
||||
"test:docker-all:stop": "npm run test:docker-all:down",
|
||||
"update-workspaces": "node update-workspaces.mjs",
|
||||
"version": "node version-bump.mjs && node update-workspaces.mjs && npm run pretty:json && git add manifest.json versions.json src/apps/cli/package.json src/apps/webpeer/package.json src/apps/webapp/package.json"
|
||||
"version": "node version-bump.mjs && node update-workspaces.mjs && npm run pretty:json && git add manifest.json versions.json src/apps/cli/package.json src/apps/webpeer/package.json src/apps/webapp/package.json",
|
||||
"test:e2e:obsidian:startup-integrity": "tsx test/e2e-obsidian/scripts/startup-integrity.ts",
|
||||
"test:e2e:obsidian:third-party-chunks": "tsx test/e2e-obsidian/scripts/third-party-chunks.ts",
|
||||
"test:e2e:obsidian:upgrade-livesync-batching": "tsx test/e2e-obsidian/scripts/upgrade-livesync-batching.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "vorotamoroz",
|
||||
@@ -144,7 +155,7 @@
|
||||
"@types/pouchdb-replication": "^6.4.7",
|
||||
"@types/transform-pouch": "^1.0.6",
|
||||
"@typescript-eslint/parser": "8.69.0",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"@vitest/coverage-v8": "^4.1.12",
|
||||
"@vrtmrz/obsidian-test-session": "0.2.6",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"esbuild": "0.28.1",
|
||||
@@ -179,8 +190,8 @@
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.69.0",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.11",
|
||||
"vite": "^8.1.6",
|
||||
"vitest": "^4.1.12",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -193,12 +204,13 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.44",
|
||||
"@vrtmrz/livesync-commonlib": "0.2.2",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"@vrtmrz/value-store": "0.1.0",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"idb": "^8.0.3",
|
||||
"markdown-it": "^14.3.2",
|
||||
"markdown-it": "^14.3.3",
|
||||
"minimatch": "^10.2.5",
|
||||
"obsidian": "^1.13.1",
|
||||
"octagonal-wheels": "^0.1.55",
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -1,6 +1,6 @@
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
|
||||
import type { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService";
|
||||
import type { IConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import type { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
||||
import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
|
||||
import type { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAPIService";
|
||||
@@ -11,7 +11,7 @@ import { BrowserSvelteDialogManager } from "./BrowserSvelteDialogManager";
|
||||
export interface LiveSyncBrowserUIServiceDependencies<T extends ServiceContext> {
|
||||
API: InjectableAPIService<T>;
|
||||
appLifecycle: AppLifecycleService<T>;
|
||||
config: ConfigService<T>;
|
||||
config: IConfigService;
|
||||
control: ControlService<T>;
|
||||
replicator: ReplicatorService<T>;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { KeyValueDatabaseFactory } from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase";
|
||||
import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser";
|
||||
import { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService";
|
||||
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
||||
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
|
||||
import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
|
||||
import type { ISettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import type { ValueDeleter, ValueStore } from "@vrtmrz/value-store";
|
||||
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAppLifecycleService";
|
||||
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
|
||||
@@ -24,10 +23,7 @@ import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/
|
||||
import { setLang, translateLiveSyncMessage } from "@/common/translation";
|
||||
import { BrowserConfirm } from "./BrowserConfirm";
|
||||
import { createBrowserKeyValueDatabaseFactory } from "./BrowserKeyValueDatabase";
|
||||
import {
|
||||
LiveSyncBrowserAPIService,
|
||||
type LiveSyncBrowserAPIServiceOptions,
|
||||
} from "./LiveSyncBrowserAPIService";
|
||||
import { LiveSyncBrowserAPIService, type LiveSyncBrowserAPIServiceOptions } from "./LiveSyncBrowserAPIService";
|
||||
import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService";
|
||||
|
||||
export interface LiveSyncBrowserSettingsPersistence {
|
||||
@@ -43,6 +39,7 @@ export interface LiveSyncBrowserRestartPolicy {
|
||||
}
|
||||
|
||||
export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
|
||||
deviceValues: ValueStore & ValueDeleter;
|
||||
context?: T;
|
||||
getSystemVaultName?: () => string;
|
||||
settings?: LiveSyncBrowserSettingsPersistence;
|
||||
@@ -51,35 +48,12 @@ export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
|
||||
API?: Omit<LiveSyncBrowserAPIServiceOptions, "confirm" | "getSystemVaultName">;
|
||||
}
|
||||
|
||||
class LiveSyncBrowserAppLifecycleService<
|
||||
T extends ServiceContext,
|
||||
> extends InjectableAppLifecycleService<T> {}
|
||||
class LiveSyncBrowserAppLifecycleService<T extends ServiceContext> extends InjectableAppLifecycleService<T> {}
|
||||
|
||||
class LiveSyncBrowserDatabaseService<T extends ServiceContext> extends DatabaseService<T> {}
|
||||
|
||||
class LiveSyncBrowserKeyValueDBService<T extends ServiceContext> extends KeyValueDBService<T> {}
|
||||
|
||||
class LiveSyncBrowserConfigService<T extends ServiceContext> extends ConfigService<T> {
|
||||
constructor(
|
||||
context: T,
|
||||
private readonly setting: ISettingService
|
||||
) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
getSmallConfig(key: string): string | null {
|
||||
return this.setting.getSmallConfig(key);
|
||||
}
|
||||
|
||||
setSmallConfig(key: string, value: string): void {
|
||||
this.setting.setSmallConfig(key, value);
|
||||
}
|
||||
|
||||
deleteSmallConfig(key: string): void {
|
||||
this.setting.deleteSmallConfig(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** LiveSync-owned service composition shared by WebApp and WebPeer. */
|
||||
export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends InjectableServiceHub<T> {
|
||||
constructor(options: LiveSyncBrowserServiceHubOptions<T>) {
|
||||
@@ -98,17 +72,14 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
||||
const setting = new InjectableSettingService(context, {
|
||||
APIService: API,
|
||||
onDisplayLanguageChanged: setLang,
|
||||
deviceValues: options.deviceValues,
|
||||
});
|
||||
const settingsPersistence = options.settings;
|
||||
setting.loadData.setHandler(
|
||||
settingsPersistence
|
||||
? () => settingsPersistence.load()
|
||||
: () => Promise.resolve(undefined)
|
||||
settingsPersistence ? () => settingsPersistence.load() : () => Promise.resolve(undefined)
|
||||
);
|
||||
setting.saveData.setHandler(
|
||||
settingsPersistence
|
||||
? (settings) => settingsPersistence.save(settings)
|
||||
: () => Promise.resolve()
|
||||
settingsPersistence ? (settings) => settingsPersistence.save(settings) : () => Promise.resolve()
|
||||
);
|
||||
|
||||
const appLifecycle = new LiveSyncBrowserAppLifecycleService(context, {
|
||||
@@ -142,7 +113,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
||||
setting,
|
||||
API,
|
||||
});
|
||||
const config = new LiveSyncBrowserConfigService(context, setting);
|
||||
const config = setting.config;
|
||||
const replicator = new InjectableReplicatorService(context, {
|
||||
settingService: setting,
|
||||
appLifecycleService: appLifecycle,
|
||||
@@ -163,8 +134,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
||||
databaseService: database,
|
||||
});
|
||||
const keyValueDB = new LiveSyncBrowserKeyValueDBService(context, {
|
||||
openKeyValueDatabase:
|
||||
options.openKeyValueDatabase ?? createBrowserKeyValueDatabaseFactory(),
|
||||
openKeyValueDatabase: options.openKeyValueDatabase ?? createBrowserKeyValueDatabaseFactory(),
|
||||
appLifecycle,
|
||||
databaseEvents,
|
||||
vault,
|
||||
@@ -209,7 +179,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
||||
}
|
||||
|
||||
export function createLiveSyncBrowserServiceHub<T extends ServiceContext>(
|
||||
options: LiveSyncBrowserServiceHubOptions<T> = {}
|
||||
options: LiveSyncBrowserServiceHubOptions<T>
|
||||
): LiveSyncBrowserServiceHub<T> {
|
||||
return new LiveSyncBrowserServiceHub(options);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -21,7 +22,8 @@ describe("LiveSync browser service context contract", () => {
|
||||
const context = createServiceContext({
|
||||
translate: (key) => `webapp:${key}`,
|
||||
});
|
||||
const hub = createLiveSyncBrowserServiceHub({ context });
|
||||
const deviceValues = new MemoryValueStore();
|
||||
const hub = createLiveSyncBrowserServiceHub({ context, deviceValues });
|
||||
|
||||
expect(observeServiceContext(context, "moduleLocalDatabase.logWaitingForReady")).toEqual({
|
||||
translation: "webapp:moduleLocalDatabase.logWaitingForReady",
|
||||
@@ -32,20 +34,17 @@ describe("LiveSync browser service context contract", () => {
|
||||
expect(SERVICE_CONTEXT_MEMBERS.filter((member) => !composition.servicesUsingExpectedContext[member])).toEqual(
|
||||
[]
|
||||
);
|
||||
expect(hub.setting.deviceValues).toBe(deviceValues);
|
||||
expect(hub.config).toBe(hub.setting.config);
|
||||
});
|
||||
|
||||
it("binds browser identity, persistence, restart policy, and native Fetch while composing the hub", async () => {
|
||||
const deviceLocalSettings = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (key: string) => deviceLocalSettings.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => deviceLocalSettings.set(key, value),
|
||||
removeItem: (key: string) => deviceLocalSettings.delete(key),
|
||||
clear: () => deviceLocalSettings.clear(),
|
||||
});
|
||||
const deviceValues = new MemoryValueStore();
|
||||
const savedSettings: (typeof DEFAULT_SETTINGS)[] = [];
|
||||
const restartCalls: string[] = [];
|
||||
const nativeFetch = vi.fn(async () => new Response("ok"));
|
||||
const options: LiveSyncBrowserServiceHubOptions<ReturnType<typeof createServiceContext>> = {
|
||||
deviceValues,
|
||||
getSystemVaultName: () => "browser-vault",
|
||||
settings: {
|
||||
load: async () => ({
|
||||
@@ -69,6 +68,8 @@ describe("LiveSync browser service context contract", () => {
|
||||
|
||||
const hub = createLiveSyncBrowserServiceHub(options);
|
||||
|
||||
expect(hub.setting.deviceValues).toBe(deviceValues);
|
||||
expect(hub.config).toBe(hub.setting.config);
|
||||
expect(hub.API.getSystemVaultName()).toBe("browser-vault");
|
||||
expect(hub.API.getPlatform()).toBe("browser");
|
||||
const response = await hub.API.nativeFetch("https://example.invalid/");
|
||||
@@ -86,4 +87,30 @@ describe("LiveSync browser service context contract", () => {
|
||||
expect(hub.appLifecycle.isReloadingScheduled()).toBe(true);
|
||||
expect(restartCalls).toEqual(["schedule", "perform", "ask:restart now"]);
|
||||
});
|
||||
|
||||
it("keeps each browser composition bound to its own device value store", async () => {
|
||||
const firstValues = new MemoryValueStore();
|
||||
const secondValues = new MemoryValueStore();
|
||||
const firstHub = createLiveSyncBrowserServiceHub({
|
||||
deviceValues: firstValues,
|
||||
getSystemVaultName: () => "first-vault",
|
||||
});
|
||||
const secondHub = createLiveSyncBrowserServiceHub({
|
||||
deviceValues: secondValues,
|
||||
getSystemVaultName: () => "second-vault",
|
||||
});
|
||||
|
||||
await firstHub.config.setValue("probe", "first");
|
||||
await secondHub.config.setValue("probe", "second");
|
||||
|
||||
expect(firstHub.setting.deviceValues).toBe(firstValues);
|
||||
expect(secondHub.setting.deviceValues).toBe(secondValues);
|
||||
expect(firstHub.config).toBe(firstHub.setting.config);
|
||||
expect(secondHub.config).toBe(secondHub.setting.config);
|
||||
expect(firstHub.config).not.toBe(secondHub.config);
|
||||
await expect(firstHub.config.getValue("probe")).resolves.toBe("first");
|
||||
await expect(secondHub.config.getValue("probe")).resolves.toBe("second");
|
||||
await expect(firstValues.getValue("first-vault-probe")).resolves.toBe("first");
|
||||
await expect(secondValues.getValue("second-vault-probe")).resolves.toBe("second");
|
||||
});
|
||||
});
|
||||
@@ -719,8 +719,8 @@ src/apps/cli/
|
||||
│ └── ServiceFileAccessImpl.ts
|
||||
├── services/
|
||||
│ ├── NodeKeyValueDBService.ts
|
||||
│ ├── NodeServiceHub.ts
|
||||
│ └── NodeSettingService.ts
|
||||
│ ├── NodeLocalStorage.ts
|
||||
│ └── NodeServiceHub.ts
|
||||
├── test/
|
||||
│ ├── test-e2e-two-vaults-common.sh
|
||||
│ ├── test-e2e-two-vaults-matrix.sh
|
||||
|
||||
@@ -88,6 +88,27 @@ describe("CLI database preparation", () => {
|
||||
await expect(main(standardIo, runner)).rejects.toThrow(`__EXIT__:${exitCode}`);
|
||||
}
|
||||
|
||||
it("loads legacy device values and persists configuration through the service-owned store", async () => {
|
||||
const localStoragePath = join(directory, ".livesync", "runtime", "local-storage.json");
|
||||
await mkdir(join(directory, ".livesync", "runtime"), { recursive: true });
|
||||
await writeFile(localStoragePath, JSON.stringify({ ["__proto__"]: "legacy value" }));
|
||||
let legacyValue: string | null | undefined;
|
||||
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
|
||||
legacyValue = await core.services.setting.deviceValues.getValue("__proto__");
|
||||
expect(core.services.config).toBe(core.services.setting.config);
|
||||
await core.services.config.setValue("bootstrap-probe", "config value");
|
||||
return false;
|
||||
});
|
||||
|
||||
await start("ls", runner);
|
||||
|
||||
expect(legacyValue).toBe("legacy value");
|
||||
const stored = JSON.parse(await readFile(localStoragePath, "utf-8")) as Record<string, string>;
|
||||
expect(Object.prototype.hasOwnProperty.call(stored, "__proto__")).toBe(true);
|
||||
expect(stored["__proto__"]).toBe("legacy value");
|
||||
expect(Object.values(stored)).toContain("config value");
|
||||
});
|
||||
|
||||
it.each([REMOTE_P2P, REMOTE_COUCHDB])(
|
||||
"restores configured P2P automation before the host connects (%s)",
|
||||
async (remoteType) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
|
||||
import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub";
|
||||
import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./services/NodeLocalStorage";
|
||||
import { LiveSyncBaseCore, type StartupDatabaseOptions } from "@/LiveSyncBaseCore";
|
||||
import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules";
|
||||
import {
|
||||
@@ -78,7 +77,6 @@ const VAULT_SYNC_MODES: Readonly<Partial<Record<CLICommand, CLIVaultSyncMode>>>
|
||||
},
|
||||
};
|
||||
|
||||
ensureGlobalNodeLocalStorage();
|
||||
defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG;
|
||||
|
||||
/** Injectable command boundary used by CLI integration probes. */
|
||||
@@ -400,8 +398,6 @@ export async function main(
|
||||
const settingsPath = options.settingsPath
|
||||
? path.resolve(options.settingsPath)
|
||||
: path.join(databasePath, SETTINGS_FILE);
|
||||
configureNodeLocalStorage(path.join(databasePath, ".livesync", "runtime", "local-storage.json"));
|
||||
|
||||
// Resolve vault path: mirror positional argument takes priority,
|
||||
// then --vault flag, otherwise fall back to databasePath.
|
||||
const vaultPath =
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.39-cli",
|
||||
"version": "1.0.42-cli",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -12,7 +12,7 @@
|
||||
"buildRun": "npm run build && npm run cli --",
|
||||
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
|
||||
"check": "tsc -p tsconfig.json",
|
||||
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
|
||||
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/services/NodeLocalStorage.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
|
||||
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
|
||||
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
|
||||
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
|
||||
@@ -52,7 +52,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.11"
|
||||
"vite": "^8.1.6",
|
||||
"vitest": "^4.1.12"
|
||||
}
|
||||
}
|
||||
@@ -1,115 +1,86 @@
|
||||
import { createDeletableValueStore, sanitiseValueStoreError, ValueStoreError } from "@vrtmrz/value-store";
|
||||
import type { ValueDeleter, ValueStore } from "@vrtmrz/value-store";
|
||||
import { fs as nodeFs, path as nodePath } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
type LocalStorageShape = {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
clear(): void;
|
||||
};
|
||||
type NodeLocalStorage = ValueStore & ValueDeleter;
|
||||
|
||||
class PersistentNodeLocalStorage {
|
||||
private storagePath: string | undefined;
|
||||
private localStore: Record<string, string> = {};
|
||||
class JsonFileValueBackend {
|
||||
private values: Map<string, string>;
|
||||
|
||||
configure(storagePath: string) {
|
||||
if (this.storagePath === storagePath) {
|
||||
return;
|
||||
}
|
||||
this.storagePath = storagePath;
|
||||
this.loadFromFile();
|
||||
constructor(private readonly storagePath: string) {
|
||||
this.values = this.loadValues();
|
||||
}
|
||||
|
||||
private loadFromFile() {
|
||||
if (!this.storagePath) {
|
||||
this.localStore = {};
|
||||
get(key: string): string | null {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
set(key: string, value: string): void {
|
||||
const nextValues = new Map(this.values);
|
||||
nextValues.set(key, value);
|
||||
this.writeValues(nextValues);
|
||||
this.values = nextValues;
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
if (!this.values.has(key)) {
|
||||
return;
|
||||
}
|
||||
const nextValues = new Map(this.values);
|
||||
nextValues.delete(key);
|
||||
this.writeValues(nextValues);
|
||||
this.values = nextValues;
|
||||
}
|
||||
|
||||
private loadValues(): Map<string, string> {
|
||||
let contents: string;
|
||||
try {
|
||||
contents = nodeFs.readFileSync(this.storagePath, "utf-8");
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
return new Map();
|
||||
}
|
||||
throw sanitiseValueStoreError(error);
|
||||
}
|
||||
|
||||
let document: unknown;
|
||||
try {
|
||||
document = JSON.parse(contents);
|
||||
} catch {
|
||||
throw new ValueStoreError("invalid-data");
|
||||
}
|
||||
if (document === null || typeof document !== "object" || Array.isArray(document)) {
|
||||
throw new ValueStoreError("invalid-data");
|
||||
}
|
||||
|
||||
const values = new Map<string, string>();
|
||||
for (const [key, value] of Object.entries(document)) {
|
||||
if (typeof value !== "string") {
|
||||
throw new ValueStoreError("invalid-data");
|
||||
}
|
||||
values.set(key, value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private writeValues(values: ReadonlyMap<string, string>): void {
|
||||
nodeFs.mkdirSync(nodePath.dirname(this.storagePath), { recursive: true });
|
||||
nodeFs.writeFileSync(this.storagePath, JSON.stringify(Object.fromEntries(values), null, 2), "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
if (typeof error !== "object" || error === null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const loaded = JSON.parse(nodeFs.readFileSync(this.storagePath, "utf-8")) as Record<string, string>;
|
||||
this.localStore = { ...loaded };
|
||||
const code = Object.getOwnPropertyDescriptor(error, "code");
|
||||
return code !== undefined && Object.prototype.hasOwnProperty.call(code, "value") && code.value === "ENOENT";
|
||||
} catch {
|
||||
this.localStore = {};
|
||||
}
|
||||
}
|
||||
|
||||
private flushToFile() {
|
||||
if (!this.storagePath) {
|
||||
return;
|
||||
}
|
||||
nodeFs.mkdirSync(nodePath.dirname(this.storagePath), { recursive: true });
|
||||
nodeFs.writeFileSync(this.storagePath, JSON.stringify(this.localStore, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.localStore[key] ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
this.localStore[key] = value;
|
||||
this.flushToFile();
|
||||
}
|
||||
|
||||
removeItem(key: string) {
|
||||
if (!(key in this.localStore)) {
|
||||
return;
|
||||
}
|
||||
delete this.localStore[key];
|
||||
this.flushToFile();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.localStore = {};
|
||||
this.flushToFile();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const persistentNodeLocalStorage = new PersistentNodeLocalStorage();
|
||||
|
||||
function createNodeLocalStorageShim(): LocalStorageShape {
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return persistentNodeLocalStorage.getItem(key);
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
persistentNodeLocalStorage.setItem(key, value);
|
||||
},
|
||||
removeItem(key: string) {
|
||||
persistentNodeLocalStorage.removeItem(key);
|
||||
},
|
||||
clear() {
|
||||
persistentNodeLocalStorage.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureGlobalNodeLocalStorage() {
|
||||
const g = compatGlobal as unknown as Record<string, unknown>;
|
||||
if (
|
||||
!("localStorage" in g) ||
|
||||
typeof (g.localStorage as Record<string, unknown> | undefined)?.getItem !== "function"
|
||||
) {
|
||||
g.localStorage = createNodeLocalStorageShim();
|
||||
}
|
||||
}
|
||||
|
||||
export function configureNodeLocalStorage(storagePath: string) {
|
||||
persistentNodeLocalStorage.configure(storagePath);
|
||||
ensureGlobalNodeLocalStorage();
|
||||
}
|
||||
|
||||
export function getNodeLocalStorageItem(key: string): string {
|
||||
return persistentNodeLocalStorage.getItem(key) ?? "";
|
||||
}
|
||||
|
||||
export function setNodeLocalStorageItem(key: string, value: string) {
|
||||
persistentNodeLocalStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
export function deleteNodeLocalStorageItem(key: string) {
|
||||
persistentNodeLocalStorage.removeItem(key);
|
||||
}
|
||||
|
||||
export function clearNodeLocalStorage() {
|
||||
persistentNodeLocalStorage.clear();
|
||||
export function createNodeLocalStorage(storagePath: string): NodeLocalStorage {
|
||||
return createDeletableValueStore(new JsonFileValueBackend(storagePath));
|
||||
}
|
||||
@@ -1,58 +1,127 @@
|
||||
import { fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { ValueStoreError } from "@vrtmrz/value-store";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearNodeLocalStorage,
|
||||
configureNodeLocalStorage,
|
||||
ensureGlobalNodeLocalStorage,
|
||||
getNodeLocalStorageItem,
|
||||
setNodeLocalStorageItem,
|
||||
} from "./NodeLocalStorage";
|
||||
import { createNodeLocalStorage } from "./NodeLocalStorage";
|
||||
|
||||
describe("NodeLocalStorage", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
clearNodeLocalStorage();
|
||||
for (const tempDir of tempDirs.splice(0)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("persists values to the configured file", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
||||
|
||||
configureNodeLocalStorage(storagePath);
|
||||
setNodeLocalStorageItem("checkpoint", "42");
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(storagePath, "utf-8")) as Record<string, string>;
|
||||
expect(saved.checkpoint).toBe("42");
|
||||
});
|
||||
|
||||
it("reloads persisted values when configured again", () => {
|
||||
it("loads and preserves legacy flat string data, including empty values and exact keys", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
||||
const specialKey = "__proto__";
|
||||
const specialValue = "line one\n雪";
|
||||
|
||||
fs.mkdirSync(path.dirname(storagePath), { recursive: true });
|
||||
fs.writeFileSync(storagePath, JSON.stringify({ persisted: "value" }, null, 2), "utf-8");
|
||||
fs.writeFileSync(storagePath, JSON.stringify({ [specialKey]: specialValue, empty: "" }), "utf-8");
|
||||
|
||||
configureNodeLocalStorage(storagePath);
|
||||
const store = createNodeLocalStorage(storagePath);
|
||||
|
||||
expect(getNodeLocalStorageItem("persisted")).toBe("value");
|
||||
await expect(store.getValue(specialKey)).resolves.toBe(specialValue);
|
||||
await expect(store.getValue("empty", "default")).resolves.toBe("");
|
||||
const saved = JSON.parse(fs.readFileSync(storagePath, "utf-8")) as Record<string, string>;
|
||||
expect(saved[specialKey]).toBe(specialValue);
|
||||
expect(saved.empty).toBe("");
|
||||
});
|
||||
|
||||
it("installs a global localStorage shim backed by the same store", () => {
|
||||
it("persists strings and objects, reloads them, and deletes keys", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
||||
const store = createNodeLocalStorage(storagePath);
|
||||
const object = { enabled: false, entries: ["value", null, 0] };
|
||||
|
||||
configureNodeLocalStorage(storagePath);
|
||||
ensureGlobalNodeLocalStorage();
|
||||
await store.setValue("empty", "");
|
||||
await store.setValue("text", "stored value");
|
||||
await store.setObject("object", object);
|
||||
|
||||
globalThis.localStorage.setItem("shared", "state");
|
||||
const reloaded = createNodeLocalStorage(storagePath);
|
||||
await expect(reloaded.getValue("empty", "default")).resolves.toBe("");
|
||||
await expect(reloaded.getValue("text")).resolves.toBe("stored value");
|
||||
await expect(reloaded.getObject("object")).resolves.toEqual(object);
|
||||
|
||||
expect(getNodeLocalStorageItem("shared")).toBe("state");
|
||||
await reloaded.delete("text");
|
||||
await expect(reloaded.getValue("text")).resolves.toBeNull();
|
||||
const saved = JSON.parse(fs.readFileSync(storagePath, "utf-8")) as Record<string, string>;
|
||||
expect(saved.text).toBeUndefined();
|
||||
expect(saved.object).toBe(JSON.stringify(object));
|
||||
});
|
||||
|
||||
it("treats a missing file as empty and creates its parent on the first write", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
||||
const store = createNodeLocalStorage(storagePath);
|
||||
|
||||
await expect(store.getValue("absent")).resolves.toBeNull();
|
||||
expect(fs.existsSync(storagePath)).toBe(false);
|
||||
await store.setValue("created", "value");
|
||||
expect(fs.existsSync(storagePath)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps separate store instances isolated by their file paths", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const first = createNodeLocalStorage(path.join(tempDir, "first", "local-storage.json"));
|
||||
const second = createNodeLocalStorage(path.join(tempDir, "second", "local-storage.json"));
|
||||
|
||||
await first.setValue("shared-key", "first");
|
||||
await second.setValue("shared-key", "second");
|
||||
|
||||
await expect(first.getValue("shared-key")).resolves.toBe("first");
|
||||
await expect(second.getValue("shared-key")).resolves.toBe("second");
|
||||
});
|
||||
|
||||
it("surfaces malformed and unreadable files as sanitised value-store errors", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const malformedPath = path.join(tempDir, "malformed.json");
|
||||
fs.writeFileSync(malformedPath, "{ malformed", "utf-8");
|
||||
|
||||
let malformedError: unknown;
|
||||
try {
|
||||
createNodeLocalStorage(malformedPath);
|
||||
} catch (error) {
|
||||
malformedError = error;
|
||||
}
|
||||
expect(malformedError).toBeInstanceOf(ValueStoreError);
|
||||
expect(malformedError).toMatchObject({ code: "invalid-data" });
|
||||
expect(fs.readFileSync(malformedPath, "utf-8")).toBe("{ malformed");
|
||||
|
||||
const unreadablePath = path.join(tempDir, "unreadable.json");
|
||||
fs.mkdirSync(unreadablePath);
|
||||
expect(() => createNodeLocalStorage(unreadablePath)).toThrowError(ValueStoreError);
|
||||
});
|
||||
|
||||
it("does not change its visible values when a write fails", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
||||
const store = createNodeLocalStorage(storagePath);
|
||||
await store.setValue("retained", "before failure");
|
||||
|
||||
fs.rmSync(storagePath);
|
||||
fs.mkdirSync(storagePath);
|
||||
|
||||
await expect(store.setValue("new", "write fails")).rejects.toBeInstanceOf(ValueStoreError);
|
||||
await expect(store.getValue("retained")).resolves.toBe("before failure");
|
||||
await expect(store.getValue("new")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("does not install or modify a global localStorage value", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const before = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
|
||||
createNodeLocalStorage(path.join(tempDir, "runtime", "local-storage.json"));
|
||||
|
||||
expect(Object.getOwnPropertyDescriptor(globalThis, "localStorage")).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AppLifecycleServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { ConfigServiceBrowserCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/ConfigServiceBrowserCompat";
|
||||
import type {
|
||||
ComponentHasResult,
|
||||
SvelteDialogManager,
|
||||
@@ -11,6 +10,7 @@ import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat
|
||||
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
|
||||
import { InjectableDatabaseEventService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableDatabaseEventService";
|
||||
import { InjectableFileProcessingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableFileProcessingService";
|
||||
import { InjectableSettingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableSettingService";
|
||||
import { PathServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectablePathService";
|
||||
import { InjectableRemoteService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableRemoteService";
|
||||
import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicationService";
|
||||
@@ -21,7 +21,7 @@ import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/
|
||||
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
||||
import { HeadlessAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/headless/HeadlessAPIService";
|
||||
import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
|
||||
import { NodeSettingService } from "./NodeSettingService";
|
||||
import { createNodeLocalStorage } from "./NodeLocalStorage";
|
||||
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { path as nodePath } from "@vrtmrz/livesync-commonlib/node";
|
||||
@@ -96,11 +96,11 @@ export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServ
|
||||
const conflict = new InjectableConflictService(context);
|
||||
const fileProcessing = new InjectableFileProcessingService(context);
|
||||
|
||||
const setting = new NodeSettingService(
|
||||
context,
|
||||
{ APIService: API, onDisplayLanguageChanged: setLang },
|
||||
localStoragePath
|
||||
);
|
||||
const setting = new InjectableSettingService<T>(context, {
|
||||
APIService: API,
|
||||
onDisplayLanguageChanged: setLang,
|
||||
deviceValues: createNodeLocalStorage(localStoragePath),
|
||||
});
|
||||
|
||||
const appLifecycle = new NodeAppLifecycleService<T>(context, {
|
||||
settingService: setting,
|
||||
@@ -132,10 +132,7 @@ export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServ
|
||||
setting,
|
||||
});
|
||||
|
||||
const config = new ConfigServiceBrowserCompat<T>(context, {
|
||||
settingService: setting,
|
||||
APIService: API,
|
||||
});
|
||||
const config = setting.config;
|
||||
|
||||
const replicator = new InjectableReplicatorService(context, {
|
||||
settingService: setting,
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { EVENT_REQUEST_RELOAD_SETTING_TAB } from "@/common/events";
|
||||
import { handlers } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { SettingService, type SettingServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
|
||||
import {
|
||||
configureNodeLocalStorage,
|
||||
deleteNodeLocalStorageItem,
|
||||
getNodeLocalStorageItem,
|
||||
setNodeLocalStorageItem,
|
||||
} from "./NodeLocalStorage";
|
||||
|
||||
export class NodeSettingService<T extends ServiceContext> extends SettingService<T> {
|
||||
constructor(context: T, dependencies: SettingServiceDependencies, storagePath: string) {
|
||||
super(context, dependencies);
|
||||
configureNodeLocalStorage(storagePath);
|
||||
this.onSettingSaved.addHandler((settings) => {
|
||||
this.context.events.emitEvent(EVENT_SETTING_SAVED, settings);
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
this.onSettingLoaded.addHandler((settings) => {
|
||||
this.context.events.emitEvent(EVENT_REQUEST_RELOAD_SETTING_TAB);
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
}
|
||||
|
||||
protected setItem(key: string, value: string) {
|
||||
setNodeLocalStorageItem(key, value);
|
||||
}
|
||||
|
||||
protected getItem(key: string): string {
|
||||
return getNodeLocalStorageItem(key);
|
||||
}
|
||||
|
||||
protected deleteItem(key: string): void {
|
||||
deleteNodeLocalStorageItem(key);
|
||||
}
|
||||
|
||||
public saveData = handlers<{ saveData: (data: ObsidianLiveSyncSettings) => Promise<void> }>().binder("saveData");
|
||||
public loadData = handlers<{ loadData: () => Promise<ObsidianLiveSyncSettings | undefined> }>().binder("loadData");
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import { useCheckRemoteSize } from "@vrtmrz/livesync-commonlib/compat/serviceFea
|
||||
import { useRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
|
||||
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
import type { ValueStore, ValueDeleter } from "@vrtmrz/value-store";
|
||||
import { createWebStorageValueStore } from "@vrtmrz/value-store/web-storage";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import {
|
||||
createLiveSyncBrowserServiceHub,
|
||||
@@ -56,6 +58,7 @@ const DEFAULT_SETTINGS: Partial<ObsidianLiveSyncSettings> = {
|
||||
export type WebAppRuntimeStatusKind = "info" | "warning" | "error" | "success";
|
||||
|
||||
export interface WebAppRuntimeOptions {
|
||||
deviceValues?: ValueStore & ValueDeleter;
|
||||
reportStatus?: (kind: WebAppRuntimeStatusKind, message: string) => void;
|
||||
scheduleReload?: (delayMilliseconds: number) => void;
|
||||
}
|
||||
@@ -71,7 +74,10 @@ export class WebAppRuntime {
|
||||
private paneHost: P2PReplicatorPaneHost | null = null;
|
||||
private restartScheduled = false;
|
||||
|
||||
constructor(rootHandle: FileSystemDirectoryHandle, options: WebAppRuntimeOptions = {}) {
|
||||
constructor(
|
||||
rootHandle: FileSystemDirectoryHandle,
|
||||
private readonly options: WebAppRuntimeOptions = {}
|
||||
) {
|
||||
this.rootHandle = rootHandle;
|
||||
this.reportStatus = options.reportStatus ?? (() => {});
|
||||
this.scheduleReload =
|
||||
@@ -89,6 +95,8 @@ export class WebAppRuntime {
|
||||
|
||||
private createServiceOptions(): LiveSyncBrowserServiceHubOptions<ServiceContext> {
|
||||
return {
|
||||
deviceValues:
|
||||
this.options.deviceValues ?? createWebStorageValueStore(compatGlobal.localStorage, { prefix: "" }),
|
||||
getSystemVaultName: () => this.rootHandle.name || "livesync-webapp",
|
||||
settings: {
|
||||
save: async (data) => {
|
||||
@@ -219,7 +227,9 @@ export class WebAppRuntime {
|
||||
useCheckRemoteSize(core);
|
||||
useRemoteConfiguration(core);
|
||||
this.p2p = useP2PReplicatorFeature(core, undefined, undefined, {
|
||||
prepareP2PSettings: useP2PSettingsPreparation(core.services.API.webCompatFetch.bind(core.services.API)),
|
||||
prepareP2PSettings: useP2PSettingsPreparation(
|
||||
core.services.API.webCompatFetch.bind(core.services.API)
|
||||
),
|
||||
});
|
||||
this.paneHost = {
|
||||
services: core.services,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.39-webapp",
|
||||
"version": "1.0.42-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
@@ -21,6 +21,6 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"svelte": "5.56.3",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.1.6"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.39-webpeer",
|
||||
"version": "1.0.42-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -24,6 +24,6 @@
|
||||
"svelte": "5.56.3",
|
||||
"svelte-check": "^4.6.0",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.1.6"
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/t
|
||||
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
||||
import { ServiceContext, type LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import type { ValueStore, ValueDeleter } from "@vrtmrz/value-store";
|
||||
import { createWebStorageValueStore } from "@vrtmrz/value-store/web-storage";
|
||||
import { unique } from "octagonal-wheels/collection";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
|
||||
@@ -20,6 +22,7 @@ import { translateLiveSyncMessage } from "@/common/translation";
|
||||
import { WEBPEER_STORE_NAME, createWebPeerPersistence } from "./WebPeerPersistence";
|
||||
|
||||
export interface WebPeerRuntimeOptions {
|
||||
deviceValues?: ValueStore & ValueDeleter;
|
||||
context?: ServiceContext;
|
||||
store?: SimpleStore<unknown>;
|
||||
deviceName?: string;
|
||||
@@ -62,6 +65,7 @@ export class WebPeerRuntime {
|
||||
this.context = options.context ?? new ServiceContext({ translate: translateLiveSyncMessage });
|
||||
this.services = createLiveSyncBrowserServiceHub<ServiceContext>({
|
||||
context: this.context,
|
||||
deviceValues: options.deviceValues ?? createWebStorageValueStore(compatGlobal.localStorage, { prefix: "" }),
|
||||
getSystemVaultName: () => options.systemVaultName ?? WEBPEER_STORE_NAME,
|
||||
settings: persistence.settings,
|
||||
restart: {
|
||||
@@ -103,7 +107,7 @@ export class WebPeerRuntime {
|
||||
await this.services.setting.loadSettings();
|
||||
const deviceName = this.options.deviceName?.trim();
|
||||
if (deviceName) {
|
||||
this.services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, deviceName);
|
||||
await this.services.config.setValue(SETTING_KEY_P2P_DEVICE_NAME, deviceName);
|
||||
}
|
||||
const opened = await this.services.database.openDatabase({
|
||||
replicator: this.services.replicator,
|
||||
@@ -192,7 +196,7 @@ export class WebPeerRuntime {
|
||||
);
|
||||
}
|
||||
|
||||
getDeviceName(): string {
|
||||
return this.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) || this.services.vault.getVaultName();
|
||||
async getDeviceName(): Promise<string> {
|
||||
return (await this.services.config.getValue(SETTING_KEY_P2P_DEVICE_NAME)) || this.services.vault.getVaultName();
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,74 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"Synchronisation was interrupted. You can continue locally, but some notes may be out of date.",
|
||||
"At least ${COUNT} changed files received": "At least ${COUNT} changed files received",
|
||||
"Continue without waiting": "Continue without waiting",
|
||||
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.":
|
||||
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.",
|
||||
"Hidden File Sync initialisation was not started.": "Hidden File Sync initialisation was not started.",
|
||||
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.":
|
||||
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.",
|
||||
"(RegExp) Skip changes to matching normal and hidden files, locally and remotely.":
|
||||
"(RegExp) Skip changes to matching normal and hidden files, locally and remotely.",
|
||||
"Unknown platform": "Unknown platform",
|
||||
"This removes only this device's record from the remote.":
|
||||
"This removes only this device's record from the remote.",
|
||||
"This remote is unlocked. A device that returns can register again.":
|
||||
"This remote is unlocked. A device that returns can register again.",
|
||||
"This remote is locked. Use the existing recovery process for devices that need to join again.":
|
||||
"This remote is locked. Use the existing recovery process for devices that need to join again.",
|
||||
"This remote is locked. If the device returns, use the existing recovery process to join it again.":
|
||||
"This remote is locked. If the device returns, use the existing recovery process to join it again.",
|
||||
"This remote does not support conditional updates, so the device record could not be removed.":
|
||||
"This remote does not support conditional updates, so the device record could not be removed.",
|
||||
"This remote does not currently have a record of joined devices.":
|
||||
"This remote does not currently have a record of joined devices.",
|
||||
"This list shows device information recorded by the remote. It does not show whether a device is online now.":
|
||||
"This list shows device information recorded by the remote. It does not show whether a device is online now.",
|
||||
"This device has been locked out. Use the existing recovery process to join again.":
|
||||
"This device has been locked out. Use the existing recovery process to join again.",
|
||||
"This device": "This device",
|
||||
"This remote has been cleaned. Use the existing recovery process to join devices again as needed.":
|
||||
"This remote has been cleaned. Use the existing recovery process to join devices again as needed.",
|
||||
"The device record was forgotten.": "The device record was forgotten.",
|
||||
"The device record was already absent.": "The device record was already absent.",
|
||||
"The device list changed while you were trying to forget this device. Refresh the list before trying again.":
|
||||
"The device list changed while you were trying to forget this device. Refresh the list before trying again.",
|
||||
"The device information could not be loaded. Close this dialogue and open Joined devices again if the remote configuration has changed.":
|
||||
"The device information could not be loaded. Close this dialogue and open Joined devices again if the remote configuration has changed.",
|
||||
"Review device records for the active CouchDB or Object Storage connection.":
|
||||
"Review device records for the active CouchDB or Object Storage connection.",
|
||||
Refresh: "Refresh",
|
||||
"Plug-in version": "Plug-in version",
|
||||
Platform: "Platform",
|
||||
"Older Self-hosted LiveSync versions detected: ${versions}.":
|
||||
"Older Self-hosted LiveSync versions detected: ${versions}.",
|
||||
"Newer Self-hosted LiveSync versions detected: ${versions}.":
|
||||
"Newer Self-hosted LiveSync versions detected: ${versions}.",
|
||||
"Reviewing and aligning Self-hosted LiveSync versions across devices is recommended.":
|
||||
"Reviewing and aligning Self-hosted LiveSync versions across devices is recommended.",
|
||||
Open: "Open",
|
||||
"Not recorded": "Not recorded",
|
||||
"Not accepted": "Not accepted",
|
||||
"No joined devices are recorded on this remote.": "No joined devices are recorded on this remote.",
|
||||
"Locked out": "Locked out",
|
||||
"Loading joined devices…": "Loading joined devices…",
|
||||
"Last recorded": "Last recorded",
|
||||
"Joined devices could not be updated. Refresh the list and try again. If the remote configuration has changed, close this dialogue and open Joined devices again.":
|
||||
"Joined devices could not be updated. Refresh the list and try again. If the remote configuration has changed, close this dialogue and open Joined devices again.",
|
||||
"Joined devices are available after database startup with an active CouchDB or Object Storage connection.":
|
||||
"Joined devices are available after database startup with an active CouchDB or Object Storage connection.",
|
||||
"Joined devices": "Joined devices",
|
||||
"Review devices": "Review devices",
|
||||
"Synchronisation between different plug-in versions is generally safe unless the connection is explicitly blocked. However, it may be less efficient or use more storage, and P2P synchronisation may be unavailable.":
|
||||
"Synchronisation between different plug-in versions is generally safe unless the connection is explicitly blocked. However, it may be less efficient or use more storage, and P2P synchronisation may be unavailable.",
|
||||
"Forgetting…": "Forgetting…",
|
||||
"Forgetting the record does not delete Vault files or revoke credentials.":
|
||||
"Forgetting the record does not delete Vault files or revoke credentials.",
|
||||
"Forget this device record?": "Forget this device record?",
|
||||
"Forget device": "Forget device",
|
||||
Close: "Close",
|
||||
Cancel: "Cancel",
|
||||
"App version": "App version",
|
||||
Accepted: "Accepted",
|
||||
"Preparing follow-up report…": "Preparing follow-up report…",
|
||||
"Cancelling local search. Checked ${COUNT} entries.": "Cancelling local search. Checked ${COUNT} entries.",
|
||||
"Search current Vault paths and current local Metadata only. Older history and remote-only files are not searched.":
|
||||
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
getObjectStorageReplicatorConfigurationIdentity,
|
||||
} from "./replicatorConfigurationIdentity";
|
||||
import {
|
||||
createCouchDBJoinedDevicesResourceFactory,
|
||||
createObjectStorageJoinedDevicesResourceFactory,
|
||||
createCouchDBConnectionProbeFactory,
|
||||
createCouchDBInitialFetchFastForwardResourceFactory,
|
||||
createCouchDBPreferredTweakProbeFactory,
|
||||
createCouchDBSecuritySeedResourceFactory,
|
||||
createCouchDBSynchronisationInformationResourceFactory,
|
||||
@@ -115,6 +118,9 @@ export function createCentralReplicatorProviderDefinitions(
|
||||
configurationIdentity: getCouchDBReplicatorConfigurationIdentity,
|
||||
create: () => Promise.resolve(new LiveSyncCouchDBReplicator(host)),
|
||||
remoteResources: {
|
||||
[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]: supportedCapability(
|
||||
createCouchDBJoinedDevicesResourceFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(createCouchDBConnectionProbeFactory(host)),
|
||||
[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK]: supportedCapability(
|
||||
createCouchDBPreferredTweakProbeFactory(host)
|
||||
@@ -125,6 +131,9 @@ export function createCentralReplicatorProviderDefinitions(
|
||||
[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: supportedCapability(
|
||||
createCouchDBSynchronisationInformationResourceFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]: supportedCapability(
|
||||
createCouchDBInitialFetchFastForwardResourceFactory(host)
|
||||
),
|
||||
},
|
||||
centralRemoteAdministration: COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
userInitiatedOneShot: supportedCapability(couchDBUserInitiatedOneShot),
|
||||
@@ -141,6 +150,9 @@ export function createCentralReplicatorProviderDefinitions(
|
||||
configurationIdentity: getObjectStorageReplicatorConfigurationIdentity,
|
||||
create: () => Promise.resolve(new LiveSyncJournalReplicator(host)),
|
||||
remoteResources: {
|
||||
[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]: supportedCapability(
|
||||
createObjectStorageJoinedDevicesResourceFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(
|
||||
createObjectStorageConnectionProbeFactory(host)
|
||||
),
|
||||
@@ -151,6 +163,7 @@ export function createCentralReplicatorProviderDefinitions(
|
||||
createObjectStorageSecuritySeedResourceFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: CAPABILITY_NOT_APPLICABLE,
|
||||
[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]: CAPABILITY_NOT_APPLICABLE,
|
||||
},
|
||||
centralRemoteAdministration: OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
userInitiatedOneShot: supportedCapability(objectStorageUserInitiatedOneShot),
|
||||
|
||||
@@ -44,7 +44,16 @@ describe("central Replicator provider definitions", () => {
|
||||
it("keeps the retained remote-resource catalogue bounded", () => {
|
||||
expect
|
||||
.soft(Object.values(REMOTE_RESOURCE_KINDS).sort())
|
||||
.toEqual(["connection", "preferred-tweak", "security-seed", "synchronisation-information"].sort());
|
||||
.toEqual(
|
||||
[
|
||||
"connection",
|
||||
"initial-fetch-fast-forward",
|
||||
"joined-devices",
|
||||
"preferred-tweak",
|
||||
"security-seed",
|
||||
"synchronisation-information",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("lets Journal prepare its own fresh Security Seed while CouchDB uses central preparation", () => {
|
||||
@@ -106,18 +115,27 @@ describe("central Replicator provider definitions", () => {
|
||||
const couchAdministration = definitions.get(REMOTE_COUCHDB)?.centralRemoteAdministration;
|
||||
const objectAdministration = definitions.get(REMOTE_MINIO)?.centralRemoteAdministration;
|
||||
|
||||
expect(Object.keys(couchResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort());
|
||||
expect(Object.keys(objectResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort());
|
||||
const resources = Object.values(REMOTE_RESOURCE_KINDS).sort();
|
||||
expect(Object.keys(couchResources ?? {}).sort()).toEqual(resources);
|
||||
expect(Object.keys(objectResources ?? {}).sort()).toEqual(resources);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(objectResources?.[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.CONNECTION].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION].kind).toBe(
|
||||
CAPABILITY_SUPPORT_KINDS.SUPPORTED
|
||||
);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]?.kind).toBe(
|
||||
CAPABILITY_SUPPORT_KINDS.SUPPORTED
|
||||
);
|
||||
expect(objectResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(objectResources?.[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION].kind).toBe(
|
||||
CAPABILITY_SUPPORT_KINDS.NOT_APPLICABLE
|
||||
);
|
||||
expect(objectResources?.[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]?.kind).toBe(
|
||||
CAPABILITY_SUPPORT_KINDS.NOT_APPLICABLE
|
||||
);
|
||||
expect(couchAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(objectAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect("activeRemoteReads" in definitions.get(REMOTE_COUCHDB)!).toBe(false);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
logger: vi.fn(),
|
||||
initialFetchFastForwardResource: undefined as unknown,
|
||||
couchDB: [] as Array<{
|
||||
host: unknown;
|
||||
isMobile: ReturnType<typeof vi.fn>;
|
||||
@@ -12,6 +13,9 @@ const mocks = vi.hoisted(() => ({
|
||||
getRemoteStatus: ReturnType<typeof vi.fn>;
|
||||
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
|
||||
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
|
||||
getJoinedDevices: ReturnType<typeof vi.fn>;
|
||||
forgetJoinedDevice: ReturnType<typeof vi.fn>;
|
||||
createInitialFetchFastForwardResource: ReturnType<typeof vi.fn>;
|
||||
closeReplication: ReturnType<typeof vi.fn>;
|
||||
}>,
|
||||
objectStorage: [] as Array<{
|
||||
@@ -20,6 +24,8 @@ const mocks = vi.hoisted(() => ({
|
||||
getRemoteStatus: ReturnType<typeof vi.fn>;
|
||||
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
|
||||
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
|
||||
getJoinedDevices: ReturnType<typeof vi.fn>;
|
||||
forgetJoinedDevice: ReturnType<typeof vi.fn>;
|
||||
closeReplication: ReturnType<typeof vi.fn>;
|
||||
}>,
|
||||
checkSyncInfo: vi.fn(async () => true),
|
||||
@@ -42,6 +48,9 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicato
|
||||
getRemoteStatus = vi.fn();
|
||||
getRemotePreferredTweakValues = vi.fn();
|
||||
getReplicationPBKDF2Salt = vi.fn();
|
||||
getJoinedDevices = vi.fn();
|
||||
forgetJoinedDevice = vi.fn();
|
||||
createInitialFetchFastForwardResource = vi.fn(() => mocks.initialFetchFastForwardResource);
|
||||
closeReplication = vi.fn();
|
||||
|
||||
constructor(host: unknown) {
|
||||
@@ -58,6 +67,8 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe
|
||||
getRemoteStatus = vi.fn();
|
||||
getRemotePreferredTweakValues = vi.fn();
|
||||
getReplicationPBKDF2Salt = vi.fn();
|
||||
getJoinedDevices = vi.fn();
|
||||
forgetJoinedDevice = vi.fn();
|
||||
closeReplication = vi.fn();
|
||||
|
||||
constructor(host: unknown) {
|
||||
@@ -69,10 +80,13 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe
|
||||
|
||||
import {
|
||||
createCouchDBConnectionProbeFactory,
|
||||
createCouchDBJoinedDevicesResourceFactory,
|
||||
createCouchDBInitialFetchFastForwardResourceFactory,
|
||||
createCouchDBPreferredTweakProbeFactory,
|
||||
createCouchDBSecuritySeedResourceFactory,
|
||||
createCouchDBSynchronisationInformationResourceFactory,
|
||||
createObjectStorageConnectionProbeFactory,
|
||||
createObjectStorageJoinedDevicesResourceFactory,
|
||||
createObjectStoragePreferredTweakProbeFactory,
|
||||
createObjectStorageSecuritySeedResourceFactory,
|
||||
} from "./replicatorResources";
|
||||
@@ -92,6 +106,7 @@ describe("replicator probe factories", () => {
|
||||
beforeEach(() => {
|
||||
mocks.couchDB.length = 0;
|
||||
mocks.objectStorage.length = 0;
|
||||
mocks.initialFetchFastForwardResource = undefined;
|
||||
mocks.checkSyncInfo.mockReset().mockResolvedValue(true);
|
||||
mocks.logger.mockClear();
|
||||
});
|
||||
@@ -226,6 +241,65 @@ describe("replicator probe factories", () => {
|
||||
expect(preferredReplicator.getRemotePreferredTweakValues).toHaveBeenCalledWith(snapshot);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
kind: "CouchDB",
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
createFactory: createCouchDBJoinedDevicesResourceFactory,
|
||||
instances: () => mocks.couchDB,
|
||||
},
|
||||
{
|
||||
kind: "Object Storage",
|
||||
remoteType: REMOTE_MINIO,
|
||||
createFactory: createObjectStorageJoinedDevicesResourceFactory,
|
||||
instances: () => mocks.objectStorage,
|
||||
},
|
||||
])(
|
||||
"keeps $kind joined-device operations on an owned settings snapshot",
|
||||
async ({ remoteType, createFactory, instances }) => {
|
||||
const host = { name: "host" };
|
||||
const source = createSettings({ remoteType });
|
||||
const snapshot = { ...source };
|
||||
const factory = createFactory(host as never);
|
||||
const resource = await factory(source);
|
||||
const nextResource = await factory(source);
|
||||
const [replicator, nextReplicator] = instances();
|
||||
expect(instances()).toHaveLength(2);
|
||||
expect(replicator.host).toBe(host);
|
||||
expect(replicator.getJoinedDevices).not.toHaveBeenCalled();
|
||||
expect(replicator.forgetJoinedDevice).not.toHaveBeenCalled();
|
||||
const devices = {
|
||||
milestonePresent: true,
|
||||
locked: false,
|
||||
cleaned: false,
|
||||
devices: [{ id: "other", accepted: true }],
|
||||
};
|
||||
replicator.getJoinedDevices.mockResolvedValue(devices);
|
||||
replicator.forgetJoinedDevice.mockResolvedValue("conflict");
|
||||
|
||||
source.couchDB_DBNAME = "changed-vault";
|
||||
source.bucket = "changed-bucket";
|
||||
await expect(resource.read()).resolves.toBe(devices);
|
||||
await expect(resource.forget("other")).resolves.toBe("conflict");
|
||||
expect(replicator.getJoinedDevices).toHaveBeenCalledWith(snapshot);
|
||||
expect(replicator.getJoinedDevices.mock.calls[0][0]).not.toBe(source);
|
||||
expect(replicator.forgetJoinedDevice).toHaveBeenCalledWith(snapshot, "other");
|
||||
|
||||
const failure = new Error("remote unavailable");
|
||||
replicator.getJoinedDevices.mockRejectedValue(failure);
|
||||
await expect(resource.read()).rejects.toBe(failure);
|
||||
replicator.forgetJoinedDevice.mockRejectedValue(failure);
|
||||
await expect(resource.forget("other")).rejects.toBe(failure);
|
||||
const disposal = resource.dispose();
|
||||
expect(resource.dispose()).toBe(disposal);
|
||||
await disposal;
|
||||
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||
expect(nextReplicator.closeReplication).not.toHaveBeenCalled();
|
||||
await nextResource.dispose();
|
||||
expect(nextReplicator.closeReplication).toHaveBeenCalledOnce();
|
||||
}
|
||||
);
|
||||
|
||||
it("shares one successful asynchronous disposal promise for every probe kind", async () => {
|
||||
const couchProbe = await createCouchDBPreferredTweakProbeFactory({} as never)(createSettings());
|
||||
const objectProbe = await createObjectStoragePreferredTweakProbeFactory({} as never)(
|
||||
@@ -370,4 +444,29 @@ describe("replicator probe factories", () => {
|
||||
await resource.dispose();
|
||||
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("creates an Initial Fetch Fast Forward resource from a settings snapshot and leaves ownership to the resource", async () => {
|
||||
const host = { name: "host" };
|
||||
const settings = createSettings();
|
||||
const snapshot = { ...settings };
|
||||
const prepare = vi.fn(async () => undefined);
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
const resource = { prepare, dispose };
|
||||
const factory = createCouchDBInitialFetchFastForwardResourceFactory(host as never);
|
||||
mocks.initialFetchFastForwardResource = resource;
|
||||
|
||||
const result = await factory(settings);
|
||||
const replicator = mocks.couchDB[0];
|
||||
|
||||
settings.couchDB_URI = "https://changed.example.test";
|
||||
expect(replicator.createInitialFetchFastForwardResource).toHaveBeenCalledWith(snapshot);
|
||||
expect(replicator.createInitialFetchFastForwardResource.mock.calls[0][0]).not.toBe(settings);
|
||||
expect(replicator.host).toBe(host);
|
||||
expect(result).toBe(resource);
|
||||
|
||||
await expect(result.prepare()).resolves.toBeUndefined();
|
||||
await result.dispose();
|
||||
expect(prepare).toHaveBeenCalledOnce();
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -14,3 +14,9 @@ export {
|
||||
type SecuritySeedResourceHost,
|
||||
} from "./securitySeed";
|
||||
export { createCouchDBSynchronisationInformationResourceFactory } from "./synchronisationInformation";
|
||||
|
||||
export {
|
||||
createCouchDBJoinedDevicesResourceFactory,
|
||||
createObjectStorageJoinedDevicesResourceFactory,
|
||||
} from "./joinedDevices";
|
||||
export { createCouchDBInitialFetchFastForwardResourceFactory } from "./initialFetchFastForward";
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { InitialFetchFastForwardResourceFactory } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
LiveSyncCouchDBReplicator,
|
||||
type LiveSyncCouchDBReplicatorEnv,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import { snapshotRemoteSettings } from "./shared";
|
||||
|
||||
/** Create a private CouchDB Replicator for a fresh Fast Fetch checkpoint handoff. */
|
||||
export function createCouchDBInitialFetchFastForwardResourceFactory(
|
||||
host: LiveSyncCouchDBReplicatorEnv
|
||||
): InitialFetchFastForwardResourceFactory {
|
||||
return (setting) => {
|
||||
const snapshot = snapshotRemoteSettings(setting);
|
||||
const replicator = new LiveSyncCouchDBReplicator(host);
|
||||
return Promise.resolve(replicator.createInitialFetchFastForwardResource(snapshot));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type {
|
||||
JoinedDevicesResource,
|
||||
JoinedDevicesResourceFactory,
|
||||
JoinedDevicesSnapshot,
|
||||
ForgetJoinedDeviceResult,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
LiveSyncCouchDBReplicator,
|
||||
type LiveSyncCouchDBReplicatorEnv,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import { createReplicatorDisposer, snapshotRemoteSettings, type ResourceReplicator } from "./shared";
|
||||
|
||||
interface JoinedDevicesReplicator extends ResourceReplicator {
|
||||
getJoinedDevices(setting: RemoteDBSettings): Promise<JoinedDevicesSnapshot>;
|
||||
forgetJoinedDevice(setting: RemoteDBSettings, deviceId: string): Promise<ForgetJoinedDeviceResult>;
|
||||
}
|
||||
|
||||
function createResource(replicator: JoinedDevicesReplicator, snapshot: RemoteDBSettings): JoinedDevicesResource {
|
||||
return {
|
||||
read: () => replicator.getJoinedDevices(snapshot),
|
||||
forget: (deviceId) => replicator.forgetJoinedDevice(snapshot, deviceId),
|
||||
dispose: createReplicatorDisposer(replicator),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCouchDBJoinedDevicesResourceFactory(
|
||||
host: LiveSyncCouchDBReplicatorEnv
|
||||
): JoinedDevicesResourceFactory {
|
||||
return (setting) =>
|
||||
Promise.resolve(createResource(new LiveSyncCouchDBReplicator(host), snapshotRemoteSettings(setting)));
|
||||
}
|
||||
|
||||
export function createObjectStorageJoinedDevicesResourceFactory(
|
||||
host: LiveSyncCouchDBReplicatorEnv
|
||||
): JoinedDevicesResourceFactory {
|
||||
return (setting) =>
|
||||
Promise.resolve(createResource(new LiveSyncJournalReplicator(host), snapshotRemoteSettings(setting)));
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
@@ -18,17 +19,7 @@ import {
|
||||
} from "./turnSettingsPrivacy";
|
||||
|
||||
class MemorySettingService extends SettingService {
|
||||
readonly items = new Map<string, string>();
|
||||
saved?: ObsidianLiveSyncSettings;
|
||||
protected setItem(key: string, value: string) {
|
||||
this.items.set(key, value);
|
||||
}
|
||||
protected getItem(key: string) {
|
||||
return this.items.get(key) ?? "";
|
||||
}
|
||||
protected deleteItem(key: string) {
|
||||
this.items.delete(key);
|
||||
}
|
||||
protected saveData(settings: ObsidianLiveSyncSettings) {
|
||||
this.saved = structuredClone(settings);
|
||||
return Promise.resolve();
|
||||
@@ -70,6 +61,7 @@ describe("managed TURN settings privacy", () => {
|
||||
const originalURI = ConnectionStringParser.serialize({ type: "p2p", settings: current });
|
||||
current.remoteConfigurations.managed.uri = originalURI;
|
||||
const service = new MemorySettingService(new ServiceContext(), {
|
||||
deviceValues: new MemoryValueStore(),
|
||||
APIService: {
|
||||
getSystemVaultName: () => "test-vault",
|
||||
getAppID: () => "test-app",
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
addIcon: vi.fn(),
|
||||
diff_match_patch: class DiffMatchPatch {},
|
||||
normalizePath: (path: string) => path,
|
||||
parseYaml: vi.fn(),
|
||||
Platform: {},
|
||||
}));
|
||||
vi.mock("./PluginDialogModal.ts", () => ({ PluginDialogModal: class PluginDialogModal {} }));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
|
||||
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||
ConflictResolveModal: class ConflictResolveModal {},
|
||||
}));
|
||||
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
cancelTask: vi.fn(),
|
||||
EVEN: Symbol("even"),
|
||||
isCustomisationSyncMetadata: vi.fn(),
|
||||
isPluginMetadata: vi.fn(),
|
||||
scheduleTask: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/common/PeriodicProcessor.ts", () => ({ PeriodicProcessor: class PeriodicProcessor {} }));
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
|
||||
eventHub: { onEvent: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({ $msg: (message: string) => message }));
|
||||
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({ getObsidianCommunityPluginManager: vi.fn() }));
|
||||
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
|
||||
import { ConfigSync, PluginDataExDisplayV2, pluginManifests } from "./CmdConfigSync";
|
||||
|
||||
const filenames = ["main.js", "manifest.json", "styles.css"];
|
||||
const content: Record<string, string> = {
|
||||
"main.js": "module.exports = class ExamplePlugin {};",
|
||||
"manifest.json": JSON.stringify({ id: "example", name: "Example", version: "1.0.0" }),
|
||||
"styles.css": ".example { color: red; }",
|
||||
};
|
||||
const fixtureOwners: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function catalogue() {
|
||||
const fixture = await createOptionalFileSyncDatabase();
|
||||
fixtureOwners.push(fixture);
|
||||
fixture.settings.syncOnlyRegEx = "\\.(?:md|png|pdf)$" as typeof fixture.settings.syncOnlyRegEx;
|
||||
const paths = filenames.map((name) => `ix:device/PLUGIN_MAIN/example%${name}` as FilePathWithPrefix);
|
||||
for (const [index, path] of paths.entries()) {
|
||||
const wireContent = `fixture-header\u200b\n\u200c${btoa(content[filenames[index]])}`;
|
||||
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(path, wireContent))).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
}
|
||||
const gates = new Map(paths.map((path) => [path, deferred()]));
|
||||
const readEntry = fixture.localDatabase.getDBEntry.bind(fixture.localDatabase);
|
||||
vi.spyOn(fixture.localDatabase, "getDBEntry").mockImplementation(async (path, ...args) => {
|
||||
await gates.get(path)?.promise;
|
||||
return readEntry(path, ...args);
|
||||
});
|
||||
const written = new Map<string, string>();
|
||||
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
|
||||
Object.assign(configSync, {
|
||||
core: {
|
||||
localDatabase: fixture.localDatabase,
|
||||
settings: fixture.settings,
|
||||
services: fixture.services,
|
||||
storageAccess: {
|
||||
ensureDir: vi.fn(async () => true),
|
||||
readHiddenFileText: vi.fn(async (path: string) => written.get(path) ?? ""),
|
||||
writeHiddenFileAuto: vi.fn(async (path: string, data: string) => {
|
||||
written.set(path, data);
|
||||
}),
|
||||
},
|
||||
},
|
||||
pluginList: [],
|
||||
pluginListGeneration: 0,
|
||||
loadedManifest_mTime: new Map(),
|
||||
updatingV2Count: 0,
|
||||
pluginScanProcessor: { clearQueue: vi.fn(), enqueue: vi.fn() },
|
||||
_log: vi.fn(),
|
||||
storeCustomisationFileV2: vi.fn(async () => true),
|
||||
});
|
||||
const updates: Promise<void>[] = [];
|
||||
const update = configSync.updatePluginListV2.bind(configSync);
|
||||
vi.spyOn(configSync, "updatePluginListV2").mockImplementation((...args) => {
|
||||
const promise = update(...args);
|
||||
updates.push(promise);
|
||||
return promise;
|
||||
});
|
||||
const release = (order = filenames) => {
|
||||
for (const name of order) gates.get(`ix:device/PLUGIN_MAIN/example%${name}` as FilePathWithPrefix)!.resolve();
|
||||
};
|
||||
return { ...fixture, configSync, updates, paths, release, written };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
pluginManifests.clear();
|
||||
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
|
||||
});
|
||||
|
||||
describe("issue #1271: rebuilding a multi-file Customisation Sync item", () => {
|
||||
it.each([
|
||||
{ order: filenames },
|
||||
{ order: [...filenames].reverse() },
|
||||
{ order: ["manifest.json", "main.js", "styles.css"] },
|
||||
])("retains all per-file documents when reads are released as $order", async ({ order }) => {
|
||||
const fixture = await catalogue();
|
||||
await fixture.configSync.reloadPluginList(false);
|
||||
fixture.release(order);
|
||||
await Promise.all(fixture.updates);
|
||||
|
||||
const documents = await fixture.database.allDocs({ keys: fixture.paths });
|
||||
expect(documents.rows).toHaveLength(3);
|
||||
expect(fixture.configSync.pluginList).toHaveLength(1);
|
||||
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename).sort()).toEqual(
|
||||
filenames.map((name) => `plugins/example/${name}`).sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("applies all three files after the first reload", async () => {
|
||||
const fixture = await catalogue();
|
||||
await fixture.configSync.reloadPluginList(false);
|
||||
fixture.release();
|
||||
await Promise.all(fixture.updates);
|
||||
|
||||
await expect(
|
||||
fixture.configSync.applyDataV2(fixture.configSync.pluginList[0] as PluginDataExDisplayV2)
|
||||
).resolves.toBe(true);
|
||||
expect([...fixture.written.keys()].sort()).toEqual(
|
||||
filenames.map((name) => `.obsidian/plugins/example/${name}`).sort()
|
||||
);
|
||||
for (const name of filenames)
|
||||
expect(fixture.written.get(`.obsidian/plugins/example/${name}`)).toBe(content[name]);
|
||||
});
|
||||
|
||||
it("fills an existing row during a second enumeration", async () => {
|
||||
const fixture = await catalogue();
|
||||
await fixture.configSync.reloadPluginList(false);
|
||||
fixture.release();
|
||||
await Promise.all(fixture.updates);
|
||||
await fixture.configSync.updatePluginList(false);
|
||||
await Promise.all(fixture.updates);
|
||||
|
||||
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename).sort()).toEqual(
|
||||
filenames.map((name) => `plugins/example/${name}`).sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("retains all files when item updates run sequentially", async () => {
|
||||
const fixture = await catalogue();
|
||||
fixture.release();
|
||||
for (const path of fixture.paths) await fixture.configSync.updatePluginListV2(false, path);
|
||||
|
||||
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename).sort()).toEqual(
|
||||
filenames.map((name) => `plugins/example/${name}`).sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("updates a different item while another item's reads are pending", async () => {
|
||||
const fixture = await catalogue();
|
||||
const otherPath = "ix:device/PLUGIN_MAIN/other%main.js" as FilePathWithPrefix;
|
||||
await fixture.localDatabase.putDBEntry(
|
||||
optionalFileEntry(otherPath, `fixture-header\u200b\n\u200c${btoa(content["main.js"])}`)
|
||||
);
|
||||
const pending = fixture.paths.map((path) => fixture.configSync.updatePluginListV2(false, path));
|
||||
try {
|
||||
await fixture.configSync.updatePluginListV2(false, otherPath);
|
||||
expect(fixture.configSync.pluginList.map((entry) => entry.name)).toEqual(["other"]);
|
||||
expect(fixture.configSync.updatingV2Count).toBe(3);
|
||||
} finally {
|
||||
fixture.release();
|
||||
await Promise.all(pending);
|
||||
}
|
||||
expect(fixture.configSync.updatingV2Count).toBe(0);
|
||||
});
|
||||
|
||||
it("continues updating an item after an earlier read fails", async () => {
|
||||
const fixture = await catalogue();
|
||||
fixture.release();
|
||||
vi.spyOn(fixture.configSync, "createPluginDataExFileV2").mockRejectedValueOnce(new Error("Read failed"));
|
||||
const failed = fixture.configSync.updatePluginListV2(false, fixture.paths[0]);
|
||||
const next = fixture.configSync.updatePluginListV2(false, fixture.paths[1]);
|
||||
|
||||
await expect(failed).rejects.toThrow("Read failed");
|
||||
await next;
|
||||
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename)).toEqual([
|
||||
"plugins/example/manifest.json",
|
||||
]);
|
||||
expect(fixture.configSync.updatingV2Count).toBe(0);
|
||||
});
|
||||
|
||||
it.each(["read", "file update"])("reload discards cached files while an earlier %s is pending", async (phase) => {
|
||||
const fixture = await catalogue();
|
||||
fixture.release();
|
||||
for (const path of fixture.paths) await fixture.configSync.updatePluginListV2(false, path);
|
||||
const earlierRead = deferred();
|
||||
const freshRead = deferred();
|
||||
const entered = deferred();
|
||||
const createFile = fixture.configSync.createPluginDataExFileV2.bind(fixture.configSync);
|
||||
let reads = 0;
|
||||
vi.spyOn(fixture.configSync, "createPluginDataExFileV2").mockImplementation(async (path) => {
|
||||
if (reads++ === 0) {
|
||||
if (phase === "read") {
|
||||
entered.resolve();
|
||||
await earlierRead.promise;
|
||||
}
|
||||
} else {
|
||||
await freshRead.promise;
|
||||
}
|
||||
return createFile(path);
|
||||
});
|
||||
if (phase === "file update") {
|
||||
const entry = fixture.configSync.pluginList[0] as PluginDataExDisplayV2;
|
||||
const setFile = entry.setFile.bind(entry);
|
||||
vi.spyOn(entry, "setFile").mockImplementationOnce(async (file) => {
|
||||
entered.resolve();
|
||||
await earlierRead.promise;
|
||||
await setFile(file);
|
||||
});
|
||||
}
|
||||
const pending = fixture.configSync.updatePluginListV2(false, fixture.paths[0]);
|
||||
try {
|
||||
await entered.promise;
|
||||
for (const path of fixture.paths.slice(1)) await fixture.database.remove(await fixture.database.get(path));
|
||||
await fixture.configSync.reloadPluginList(false);
|
||||
earlierRead.resolve();
|
||||
await pending;
|
||||
} finally {
|
||||
earlierRead.resolve();
|
||||
freshRead.resolve();
|
||||
await Promise.all(fixture.updates);
|
||||
}
|
||||
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename)).toEqual([
|
||||
"plugins/example/main.js",
|
||||
]);
|
||||
});
|
||||
|
||||
it("clearing the disabled feature prevents pending updates from repopulating the list", async () => {
|
||||
const fixture = await catalogue();
|
||||
const pending = fixture.paths.map((path) => fixture.configSync.updatePluginListV2(false, path));
|
||||
fixture.settings.usePluginSync = false;
|
||||
await fixture.configSync.updatePluginList(false);
|
||||
fixture.release();
|
||||
await Promise.all(pending);
|
||||
|
||||
expect(fixture.configSync.pluginList).toEqual([]);
|
||||
expect(fixture.configSync.updatingV2Count).toBe(0);
|
||||
});
|
||||
|
||||
it("a superseded enumeration cannot reintroduce a deleted item", async () => {
|
||||
const fixture = await catalogue();
|
||||
const obsoletePath = "ix:device/PLUGIN_MAIN/obsolete%main.js" as FilePathWithPrefix;
|
||||
await fixture.localDatabase.putDBEntry(
|
||||
optionalFileEntry(obsoletePath, `fixture-header\u200b\n\u200c${btoa(content["main.js"])}`)
|
||||
);
|
||||
const enumerate = fixture.localDatabase.findEntries.bind(fixture.localDatabase);
|
||||
type EnumeratedEntry = ReturnType<typeof enumerate> extends AsyncGenerator<infer Entry> ? Entry : never;
|
||||
const obsoleteDocs: EnumeratedEntry[] = [];
|
||||
for await (const doc of enumerate(obsoletePath, `${obsoletePath}\u{10ffff}`, { include_docs: true })) {
|
||||
obsoleteDocs.push(doc);
|
||||
}
|
||||
const entered = deferred();
|
||||
const release = deferred();
|
||||
let scans = 0;
|
||||
vi.spyOn(fixture.localDatabase, "findEntries").mockImplementation((...args) => {
|
||||
if (scans++ !== 0) return enumerate(...args);
|
||||
return (async function* () {
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
yield* obsoleteDocs;
|
||||
})();
|
||||
});
|
||||
const pending = fixture.configSync.updatePluginList(false);
|
||||
try {
|
||||
await entered.promise;
|
||||
await fixture.database.remove(await fixture.database.get(obsoletePath));
|
||||
await fixture.configSync.reloadPluginList(false);
|
||||
} finally {
|
||||
release.resolve();
|
||||
fixture.release();
|
||||
await pending;
|
||||
await Promise.all(fixture.updates);
|
||||
}
|
||||
|
||||
expect(fixture.configSync.pluginList.map((entry) => entry.name)).toEqual(["example"]);
|
||||
expect(fixture.configSync.pluginList[0].files).toHaveLength(3);
|
||||
expect(fixture.configSync.updatingV2Count).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
addIcon: vi.fn(),
|
||||
diff_match_patch: class DiffMatchPatch {},
|
||||
normalizePath: vi.fn((path: string) => path),
|
||||
parseYaml: vi.fn(),
|
||||
Platform: {},
|
||||
}));
|
||||
vi.mock("./PluginDialogModal.ts", () => ({
|
||||
PluginDialogModal: class PluginDialogModal {},
|
||||
}));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||
JsonResolveModal: class JsonResolveModal {},
|
||||
}));
|
||||
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||
ConflictResolveModal: class ConflictResolveModal {},
|
||||
}));
|
||||
// The readiness helpers come from the real LiveSyncCommands, so only its log view dependency is replaced.
|
||||
vi.mock("@/modules/features/ModuleLog.ts", () => ({
|
||||
MARK_DONE: "",
|
||||
}));
|
||||
vi.mock("@/common/types.ts", () => ({
|
||||
ICXHeader: "ix:",
|
||||
PERIODIC_PLUGIN_SWEEP: 60,
|
||||
}));
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
cancelTask: vi.fn(),
|
||||
EVEN: Symbol("even"),
|
||||
isCustomisationSyncMetadata: vi.fn(),
|
||||
isPluginMetadata: vi.fn(),
|
||||
scheduleTask: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||
PeriodicProcessor: class PeriodicProcessor {},
|
||||
}));
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
|
||||
eventHub: {
|
||||
onEvent: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: vi.fn((message: string) => message),
|
||||
}));
|
||||
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
|
||||
getObsidianCommunityPluginManager: vi.fn(),
|
||||
}));
|
||||
|
||||
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { getDocData } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { ConfigSync } from "./CmdConfigSync";
|
||||
import { createOptionalFileSyncDatabase } from "../../../test/fixtures/optionalFileSyncDatabase";
|
||||
|
||||
const path = ".obsidian/snippets/test.css" as FilePath;
|
||||
const fixtures: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
|
||||
|
||||
async function createConfigSync(useV2 = false) {
|
||||
const fixture = await createOptionalFileSyncDatabase();
|
||||
fixtures.push(fixture);
|
||||
fixture.settings.usePluginSyncV2 = useV2;
|
||||
const files = new Map<string, { content: string; mtime: number }>([
|
||||
[path, { content: "body { color: red; }", mtime: 2000 }],
|
||||
]);
|
||||
const storageAccess = {
|
||||
statHidden: vi.fn(async (file: string) => {
|
||||
const entry = files.get(file);
|
||||
return entry
|
||||
? { type: "file" as const, ctime: 1000, mtime: entry.mtime, size: entry.content.length }
|
||||
: null;
|
||||
}),
|
||||
readHiddenFileBinary: vi.fn(async (file: string) => {
|
||||
const entry = files.get(file);
|
||||
if (!entry) throw new Error(`Missing fixture: ${file}`);
|
||||
return new TextEncoder().encode(entry.content).buffer;
|
||||
}),
|
||||
};
|
||||
const services = {
|
||||
...fixture.services,
|
||||
appLifecycle: { isReady: () => false, isSuspended: () => true },
|
||||
database: { ...fixture.services.database, isDatabaseReady: vi.fn(() => true) },
|
||||
path: {
|
||||
...fixture.services.path,
|
||||
isMarkedAsSameChanges: vi.fn(),
|
||||
markChangesAreSame: vi.fn(),
|
||||
},
|
||||
};
|
||||
const config = Object.create(ConfigSync.prototype) as ConfigSync;
|
||||
Object.assign(config, {
|
||||
core: { settings: fixture.settings, localDatabase: fixture.localDatabase, storageAccess, services },
|
||||
_log: vi.fn(),
|
||||
updatePluginList: vi.fn(async () => undefined),
|
||||
updatePluginListV2: vi.fn(async () => undefined),
|
||||
recentProcessedInternalFiles: [],
|
||||
});
|
||||
const databasePath = useV2 ? config.filenameWithUnifiedKey(path) : config.filenameToUnifiedKey(path);
|
||||
return { fixture, config, files, services, storageAccess, databasePath };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
for (const fixture of fixtures.splice(0)) await fixture.dispose();
|
||||
});
|
||||
|
||||
describe("Customisation Sync optional save completion", () => {
|
||||
const flush = { whenFlushRequested: Promise.resolve() };
|
||||
|
||||
it("waits for the database write while the application is suspended or still starting", async () => {
|
||||
const { fixture, config } = await createConfigSync();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const put = fixture.localDatabase.putDBEntry.bind(fixture.localDatabase);
|
||||
const spy = vi.spyOn(fixture.localDatabase, "putDBEntry").mockImplementation(async (...args) => {
|
||||
await gate;
|
||||
return await put(...args);
|
||||
});
|
||||
let completed = false;
|
||||
const result = config._processOptionalFileEventWithResult(path, flush).then((outcome) => {
|
||||
completed = true;
|
||||
return outcome;
|
||||
});
|
||||
await vi.waitFor(() => expect(spy).toHaveBeenCalledOnce());
|
||||
expect(completed).toBe(false);
|
||||
release();
|
||||
await expect(result).resolves.toEqual({ kind: "completed" });
|
||||
});
|
||||
|
||||
it.each([false, true])("confirms identical contents and stores a same-time edit (V2: %s)", async (useV2) => {
|
||||
const { fixture, config, databasePath, files } = await createConfigSync(useV2);
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
|
||||
const first = await fixture.database.get(databasePath);
|
||||
await config._processOptionalFileEventWithResult(path, flush);
|
||||
expect((await fixture.database.get(databasePath))._rev).toBe(first._rev);
|
||||
files.get(path)!.content = "body { color: tan; }";
|
||||
await config._processOptionalFileEventWithResult(path, flush);
|
||||
expect((await fixture.database.get(databasePath))._rev).not.toBe(first._rev);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ useV2: false, throws: false },
|
||||
{ useV2: true, throws: false },
|
||||
{ useV2: false, throws: true },
|
||||
{ useV2: true, throws: true },
|
||||
])("rejects a failed write and permits the unchanged notification to retry: %j", async ({ useV2, throws }) => {
|
||||
const { fixture, config, databasePath } = await createConfigSync(useV2);
|
||||
const failure = new Error("temporary write failure");
|
||||
const put = vi.spyOn(fixture.localDatabase, "putDBEntry");
|
||||
if (throws) put.mockRejectedValueOnce(failure);
|
||||
else put.mockResolvedValueOnce(false);
|
||||
const failed = config._processOptionalFileEventWithResult(path, flush);
|
||||
if (throws) await expect(failed).rejects.toBe(failure);
|
||||
else await expect(failed).rejects.toThrow("Could not save customisation");
|
||||
await expect(fixture.localDatabase.getDBEntry(databasePath)).resolves.toBe(false);
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
|
||||
expect(put).toHaveBeenCalledTimes(2);
|
||||
expect(await fixture.localDatabase.getDBEntry(databasePath)).not.toBe(false);
|
||||
});
|
||||
|
||||
it.each([false, true])("confirms deletion and treats a repeated deletion as complete (V2: %s)", async (useV2) => {
|
||||
const { fixture, config, databasePath, files } = await createConfigSync(useV2);
|
||||
await config._processOptionalFileEventWithResult(path, flush);
|
||||
files.delete(path);
|
||||
const put = vi.spyOn(fixture.localDatabase, "putRaw");
|
||||
put.mockResolvedValueOnce({ ok: false, id: "failed", rev: "" });
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).rejects.toThrow(
|
||||
"Could not delete customisation"
|
||||
);
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
|
||||
const deleted = await fixture.database.get(databasePath);
|
||||
expect("deleted" in deleted && deleted.deleted).toBe(true);
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
|
||||
expect((await fixture.database.get(databasePath))._rev).toBe(deleted._rev);
|
||||
});
|
||||
|
||||
it("distinguishes a different owner and unmet prerequisites", async () => {
|
||||
const { config, services, storageAccess } = await createConfigSync();
|
||||
await expect(config._processOptionalFileEventWithResult("notes.md" as FilePath, flush)).resolves.toEqual({
|
||||
kind: "not-applicable",
|
||||
});
|
||||
services.database.isDatabaseReady.mockReturnValue(false);
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({
|
||||
kind: "veto",
|
||||
value: "database-not-ready",
|
||||
});
|
||||
services.database.isDatabaseReady.mockReturnValue(true);
|
||||
vi.spyOn(services.setting, "getDeviceAndVaultName").mockReturnValue("");
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({
|
||||
kind: "veto",
|
||||
value: "device-name-not-configured",
|
||||
});
|
||||
expect(storageAccess.readHiddenFileBinary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves every V2 file when neighbouring plug-in files arrive together", async () => {
|
||||
const { fixture, config, files } = await createConfigSync(true);
|
||||
const main = ".obsidian/plugins/example/main.js" as FilePath;
|
||||
const style = ".obsidian/plugins/example/styles.css" as FilePath;
|
||||
files.set(main, { content: "module.exports = {};", mtime: 3000 });
|
||||
files.set(style, { content: "body {}", mtime: 3000 });
|
||||
await Promise.all([
|
||||
config._processOptionalFileEventWithResult(main, flush),
|
||||
config._processOptionalFileEventWithResult(style, flush),
|
||||
]);
|
||||
expect(await fixture.localDatabase.getDBEntry(config.filenameWithUnifiedKey(main))).not.toBe(false);
|
||||
expect(await fixture.localDatabase.getDBEntry(config.filenameWithUnifiedKey(style))).not.toBe(false);
|
||||
});
|
||||
|
||||
it("does not acknowledge a database deletion which was not accepted", async () => {
|
||||
const { fixture, config, databasePath } = await createConfigSync();
|
||||
await config.storeCustomizationFiles(path);
|
||||
const original = await fixture.localDatabase.getDBEntry(databasePath);
|
||||
expect(original).not.toBe(false);
|
||||
vi.spyOn(fixture.localDatabase, "putRaw").mockResolvedValueOnce({ ok: false, id: "failed", rev: "" });
|
||||
await expect(config.deleteConfigOnDatabase(databasePath)).resolves.toBe(false);
|
||||
const unchanged = await fixture.localDatabase.getDBEntry(databasePath);
|
||||
expect(unchanged && getDocData(unchanged.data)).toBe(original && getDocData(original.data));
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,10 @@ import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError"
|
||||
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
|
||||
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
|
||||
|
||||
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
||||
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import { PendingConfigSaves } from "./PendingConfigSaves";
|
||||
|
||||
const d = "\u200b";
|
||||
const d2 = "\n";
|
||||
const UPDATED_CONFIGURATION_NOTICE_KEY = "config-sync:updated-configuration";
|
||||
@@ -412,6 +416,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
periodicPluginSweepProcessor = new PeriodicProcessor(this.core, async () => await this.scanAllConfigFiles(false));
|
||||
|
||||
pluginList: IPluginDataExDisplay[] = [];
|
||||
private pluginListGeneration = 0;
|
||||
showPluginSyncModal() {
|
||||
if (!this.isThisModuleEnabled()) {
|
||||
return;
|
||||
@@ -532,6 +537,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
async reloadPluginList(showMessage: boolean) {
|
||||
this.pluginListGeneration++;
|
||||
this.pluginList = [];
|
||||
this.loadedManifest_mTime.clear();
|
||||
pluginList.set(this.pluginList);
|
||||
@@ -761,12 +767,16 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
updatingV2Count = 0;
|
||||
|
||||
async updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise<void> {
|
||||
const generation = this.pluginListGeneration;
|
||||
const isCurrent = () => generation === this.pluginListGeneration && this.isThisModuleEnabled();
|
||||
try {
|
||||
this.updatingV2Count++;
|
||||
pluginV2Progress.set(this.updatingV2Count);
|
||||
// const unifiedFilenameWithKey = this.filenameWithUnifiedKey(updatedDocumentPath);
|
||||
const { pathV1 } = this.parseUnifiedPath(unifiedFilenameWithKey);
|
||||
|
||||
await serialized(`config:catalogue:${pathV1}`, async () => {
|
||||
if (!isCurrent()) return;
|
||||
const oldEntry = this.pluginList.find((e) => e.documentPath == pathV1);
|
||||
let entry: PluginDataExDisplayV2 | undefined = undefined;
|
||||
|
||||
@@ -780,6 +790,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
if (!entry) return;
|
||||
const file = await this.createPluginDataExFileV2(unifiedFilenameWithKey);
|
||||
if (!isCurrent()) return;
|
||||
if (file) {
|
||||
await entry.setFile(file);
|
||||
} else {
|
||||
@@ -788,6 +799,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
this.pluginList = this.pluginList.filter((e) => e.documentPath != pathV1);
|
||||
}
|
||||
}
|
||||
if (!isCurrent()) return;
|
||||
const newList = this.pluginList.filter((e) => e.documentPath != entry.documentPath);
|
||||
newList.push(entry);
|
||||
this.pluginList = newList;
|
||||
@@ -795,6 +807,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
scheduleTask("updatePluginListV2", 100, () => {
|
||||
pluginList.set(this.pluginList);
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
this.updatingV2Count--;
|
||||
pluginV2Progress.set(this.updatingV2Count);
|
||||
@@ -871,11 +884,13 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
|
||||
async updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise<void> {
|
||||
if (!this.isThisModuleEnabled()) {
|
||||
this.pluginListGeneration++;
|
||||
this.pluginScanProcessor.clearQueue();
|
||||
this.pluginList = [];
|
||||
pluginList.set(this.pluginList);
|
||||
return;
|
||||
}
|
||||
const generation = this.pluginListGeneration;
|
||||
try {
|
||||
this.updatingV2Count++;
|
||||
pluginV2Progress.set(this.updatingV2Count);
|
||||
@@ -888,6 +903,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
})
|
||||
: this.localDatabase.findEntries(ICXHeader + "", `${ICXHeader}\u{10ffff}`, { include_docs: true });
|
||||
for await (const v of plugins) {
|
||||
if (generation !== this.pluginListGeneration || !this.isThisModuleEnabled()) return;
|
||||
if (v.deleted || v._deleted) continue;
|
||||
if (v.path.indexOf("%") !== -1) {
|
||||
fireAndForget(() => this.updatePluginListV2(showMessage, v.path));
|
||||
@@ -1206,7 +1222,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
|
||||
recentProcessedInternalFiles = [] as string[];
|
||||
async makeEntryFromFile(path: FilePath): Promise<false | PluginDataExFile> {
|
||||
async makeEntryFromFile(path: FilePath, requireCompletion = false): Promise<false | PluginDataExFile> {
|
||||
const stat = await this.core.storageAccess.statHidden(path);
|
||||
let version: string | undefined;
|
||||
let displayName: string | undefined;
|
||||
@@ -1240,6 +1256,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
} catch (ex) {
|
||||
this._log(`The file ${path} could not be encoded`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (requireCompletion) throw ex;
|
||||
return false;
|
||||
}
|
||||
const mtime = stat.mtime;
|
||||
@@ -1254,6 +1271,15 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
|
||||
async storeCustomisationFileV2(path: FilePath, term: string, force = false) {
|
||||
return await this.storeCustomisationFileV2Core(path, term, force, false);
|
||||
}
|
||||
|
||||
private async storeCustomisationFileV2Core(
|
||||
path: FilePath,
|
||||
term: string,
|
||||
force: boolean,
|
||||
requireCompletion: boolean
|
||||
) {
|
||||
const vf = this.filenameWithUnifiedKey(path, term);
|
||||
return await serialized(`plugin-${vf}`, async () => {
|
||||
const prefixedFileName = vf;
|
||||
@@ -1261,7 +1287,8 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
const id = await this.path2id(prefixedFileName);
|
||||
const stat = await this.core.storageAccess.statHidden(path);
|
||||
if (!stat) {
|
||||
return false;
|
||||
if (!requireCompletion) return false;
|
||||
return await this.deleteConfigOnDatabaseCore(vf, force, true);
|
||||
}
|
||||
const mtime = stat.mtime;
|
||||
const content = await this.core.storageAccess.readHiddenFileBinary(path);
|
||||
@@ -1285,7 +1312,10 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
eden: {},
|
||||
};
|
||||
} else {
|
||||
if (this.services.path.isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN) {
|
||||
if (
|
||||
!requireCompletion &&
|
||||
this.services.path.isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN
|
||||
) {
|
||||
this._log(
|
||||
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Already checked the same)`,
|
||||
LOG_LEVEL_DEBUG
|
||||
@@ -1300,7 +1330,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
const dataStart = dataSrc.indexOf(DUMMY_END);
|
||||
const oldContent = dataSrc.substring(dataStart + DUMMY_END.length);
|
||||
const oldContentArray = base64ToArrayBuffer(oldContent);
|
||||
if (await isDocContentSame(oldContentArray, content)) {
|
||||
if (!old.deleted && (await isDocContentSame(oldContentArray, content))) {
|
||||
this._log(
|
||||
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (the same content)`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
@@ -1320,24 +1350,36 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
};
|
||||
}
|
||||
const ret = await this.localDatabase.putDBEntry(saveData);
|
||||
if (requireCompletion && (!ret || !ret.ok))
|
||||
throw new LiveSyncError(`Could not save customisation: ${path}`);
|
||||
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
|
||||
fireAndForget(() => this.updatePluginListV2(false, this.filenameWithUnifiedKey(path)));
|
||||
return ret;
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (requireCompletion) throw ex;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
async storeCustomizationFiles(path: FilePath, termOverRide?: string) {
|
||||
return await this.storeCustomizationFilesCore(path, termOverRide, false);
|
||||
}
|
||||
|
||||
private async storeCustomizationFilesCore(
|
||||
path: FilePath,
|
||||
termOverRide: string | undefined,
|
||||
requireCompletion: boolean
|
||||
) {
|
||||
const term = termOverRide || this.services.setting.getDeviceAndVaultName();
|
||||
if (term == "") {
|
||||
this._log($msg("We have to configure the device name"), LOG_LEVEL_NOTICE);
|
||||
if (requireCompletion) throw new LiveSyncError("The device name is not configured");
|
||||
return;
|
||||
}
|
||||
if (this.useV2) {
|
||||
return await this.storeCustomisationFileV2(path, term);
|
||||
return await this.storeCustomisationFileV2Core(path, term, false, requireCompletion);
|
||||
}
|
||||
const vf = this.filenameToUnifiedKey(path, term);
|
||||
// console.warn(`Storing ${path} to ${bareVF} :--> ${keyedVF}`);
|
||||
@@ -1378,7 +1420,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
fileTargets = ["manifest.json", "theme.css"].map((e) => `${parentPath}/${e}` as FilePath);
|
||||
}
|
||||
for (const target of fileTargets) {
|
||||
const data = await this.makeEntryFromFile(target);
|
||||
const data = await this.makeEntryFromFile(target, requireCompletion);
|
||||
if (data == false) {
|
||||
this._log(`Config: skipped (Possibly is not exist): ${target} `, LOG_LEVEL_VERBOSE);
|
||||
continue;
|
||||
@@ -1398,9 +1440,9 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
// this._log(`Configuration saving: ${prefixedFileName}`);
|
||||
if (dt.files.length == 0) {
|
||||
this._log(`Nothing left: deleting.. ${path}`);
|
||||
await this.deleteConfigOnDatabase(prefixedFileName);
|
||||
const deleted = await this.deleteConfigOnDatabaseCore(prefixedFileName, false, requireCompletion);
|
||||
await this.updatePluginList(false, prefixedFileName);
|
||||
return;
|
||||
return requireCompletion ? deleted : undefined;
|
||||
}
|
||||
|
||||
const content = createTextBlob(serialize(dt));
|
||||
@@ -1422,7 +1464,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
eden: {},
|
||||
};
|
||||
} else {
|
||||
if (old.mtime == mtime) {
|
||||
if (!requireCompletion && old.mtime == mtime) {
|
||||
// this._log(`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same time)`, LOG_LEVEL_VERBOSE);
|
||||
return true;
|
||||
}
|
||||
@@ -1443,7 +1485,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
});
|
||||
const isSame = (await Promise.all(diffs)).every((e) => e == true);
|
||||
if (isSame) {
|
||||
if (isSame && !old.deleted) {
|
||||
this._log(
|
||||
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same content)`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
@@ -1464,16 +1506,51 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
};
|
||||
}
|
||||
const ret = await this.localDatabase.putDBEntry(saveData);
|
||||
if (requireCompletion && (!ret || !ret.ok))
|
||||
throw new LiveSyncError(`Could not save customisation: ${path}`);
|
||||
await this.updatePluginList(false, saveData.path);
|
||||
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
|
||||
return ret;
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (requireCompletion) throw ex;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
private pendingOptionalSaves?: PendingConfigSaves;
|
||||
|
||||
async _processOptionalFileEventWithResult(
|
||||
path: FilePath,
|
||||
options?: OptionalFileEventOptions
|
||||
): Promise<HandlerOutcome<void, string>> {
|
||||
if (!this.isThisModuleEnabled() || !this.isTargetPath(path)) return { kind: "not-applicable" };
|
||||
const configDir = normalizePath(this.configDir);
|
||||
const handledElsewhere = Object.values(this.settings.pluginSyncExtendedSetting)
|
||||
.filter((entry) => entry.mode !== MODE_SELECTIVE && entry.mode !== MODE_SHINY)
|
||||
.flatMap((entry) => entry.files)
|
||||
.some((file) => `${configDir}/${file}`.toLowerCase().startsWith(path.toLowerCase()));
|
||||
if (handledElsewhere) return { kind: "not-applicable" };
|
||||
if (!this._isDatabaseReady()) return { kind: "veto", value: "database-not-ready" };
|
||||
const term = this.services.setting.getDeviceAndVaultName();
|
||||
if (!term) return { kind: "veto", value: "device-name-not-configured" };
|
||||
const stat = await this.core.storageAccess.statHidden(path);
|
||||
if (stat && stat.type !== "file") return { kind: "not-applicable" };
|
||||
|
||||
// V1 saves a complete file group; V2 persists each file separately.
|
||||
const key = this.useV2 ? this.filenameWithUnifiedKey(path, term) : this.filenameToUnifiedKey(path, term);
|
||||
this.pendingOptionalSaves ??= new PendingConfigSaves();
|
||||
await this.pendingOptionalSaves.enqueue(
|
||||
key,
|
||||
async () => {
|
||||
await this.storeCustomizationFilesCore(path, term, true);
|
||||
},
|
||||
options?.whenFlushRequested
|
||||
);
|
||||
return { kind: "completed" };
|
||||
}
|
||||
|
||||
async _anyProcessOptionalFileEvent(path: FilePath): Promise<boolean> {
|
||||
return await this.watchVaultRawEventsAsync(path);
|
||||
}
|
||||
@@ -1615,6 +1692,14 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
|
||||
async deleteConfigOnDatabase(prefixedFileName: FilePathWithPrefix, forceWrite = false) {
|
||||
return await this.deleteConfigOnDatabaseCore(prefixedFileName, forceWrite, false);
|
||||
}
|
||||
|
||||
private async deleteConfigOnDatabaseCore(
|
||||
prefixedFileName: FilePathWithPrefix,
|
||||
forceWrite: boolean,
|
||||
requireCompletion: boolean
|
||||
) {
|
||||
// const id = await this.path2id(prefixedFileName);
|
||||
const mtime = new Date().getTime();
|
||||
return await serialized("file-x-" + prefixedFileName, async () => {
|
||||
@@ -1640,13 +1725,15 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
type: "newnote",
|
||||
};
|
||||
}
|
||||
await this.localDatabase.putRaw(saveData);
|
||||
const result = await this.localDatabase.putRaw(saveData);
|
||||
if (!result.ok) throw new LiveSyncError(`Could not delete customisation: ${prefixedFileName}`);
|
||||
await this.updatePluginList(false, prefixedFileName);
|
||||
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Done`);
|
||||
return true;
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (requireCompletion) throw ex;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
@@ -1756,6 +1843,9 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
services.fileProcessing.processOptionalFileEvent.addHandler(this._anyProcessOptionalFileEvent.bind(this));
|
||||
services.fileProcessing.processOptionalFileEventWithResult?.addHandler(
|
||||
this._processOptionalFileEventWithResult.bind(this)
|
||||
);
|
||||
services.conflict.getOptionalConflictCheckMethod.addHandler(this._anyGetOptionalConflictCheckMethod.bind(this));
|
||||
services.replication.processVirtualDocument.addHandler(this._anyModuleParsedReplicationResultItem.bind(this));
|
||||
services.setting.onRealiseSetting.addHandler(this._everyRealizeSettingSyncMode.bind(this));
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { compatGlobal, type CompatTimeoutHandle } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
type PendingSave = {
|
||||
save: () => Promise<void>;
|
||||
completion: Promise<void>;
|
||||
resolve: () => void;
|
||||
reject: (error: unknown) => void;
|
||||
timer?: CompatTimeoutHandle;
|
||||
started: boolean;
|
||||
};
|
||||
|
||||
/** Owns the quiet-period delay and completion of coalesced customisation saves. */
|
||||
export class PendingConfigSaves {
|
||||
private readonly pending = new Map<string, PendingSave>();
|
||||
|
||||
enqueue(key: string, save: () => Promise<void>, whenFlushRequested?: Promise<void>): Promise<void> {
|
||||
let group = this.pending.get(key);
|
||||
if (!group) {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const completion = new Promise<void>((onResolve, onReject) => {
|
||||
resolve = onResolve;
|
||||
reject = onReject;
|
||||
});
|
||||
group = { save, completion, resolve, reject, started: false };
|
||||
this.pending.set(key, group);
|
||||
} else {
|
||||
group.save = save;
|
||||
if (group.timer !== undefined) compatGlobal.clearTimeout(group.timer);
|
||||
}
|
||||
const captured = group;
|
||||
group.timer = compatGlobal.setTimeout(() => this.run(key, captured), 100);
|
||||
// Capture this group: a late flush of an earlier event must not release
|
||||
// a newer group which happens to use the same customisation key.
|
||||
void whenFlushRequested?.then(
|
||||
() => this.run(key, captured),
|
||||
() => {}
|
||||
);
|
||||
return group.completion;
|
||||
}
|
||||
|
||||
private run(key: string, group: PendingSave): void {
|
||||
if (group.started) return;
|
||||
group.started = true;
|
||||
if (group.timer !== undefined) compatGlobal.clearTimeout(group.timer);
|
||||
this.pending.delete(key);
|
||||
void Promise.resolve().then(group.save).then(group.resolve, group.reject);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { PendingConfigSaves } from "./PendingConfigSaves";
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<void>((onResolve, onReject) => {
|
||||
resolve = onResolve;
|
||||
reject = onReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("pending customisation saves", () => {
|
||||
it("retains the quiet period and completes every coalesced notification after the actual save", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pending = new PendingConfigSaves();
|
||||
const save = deferred();
|
||||
const firstSave = vi.fn(async () => {});
|
||||
const lastSave = vi.fn(() => save.promise);
|
||||
const first = pending.enqueue("plugin", firstSave);
|
||||
await vi.advanceTimersByTimeAsync(80);
|
||||
const second = pending.enqueue("plugin", lastSave);
|
||||
let completed = false;
|
||||
const all = Promise.all([first, second]).then(() => {
|
||||
completed = true;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(99);
|
||||
expect(lastSave).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(firstSave).not.toHaveBeenCalled();
|
||||
expect(lastSave).toHaveBeenCalledOnce();
|
||||
expect(completed).toBe(false);
|
||||
save.resolve();
|
||||
await all;
|
||||
expect(completed).toBe(true);
|
||||
});
|
||||
|
||||
it("flushes the captured group without waiting for or releasing later notifications", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pending = new PendingConfigSaves();
|
||||
const flush = deferred();
|
||||
const lateFlush = deferred();
|
||||
const firstSave = deferred();
|
||||
const work = vi.fn(() => firstSave.promise);
|
||||
const first = pending.enqueue("plugin", work, flush.promise);
|
||||
const coalesced = pending.enqueue("plugin", work, lateFlush.promise);
|
||||
flush.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(work).toHaveBeenCalledOnce();
|
||||
const nextWork = vi.fn(async () => {});
|
||||
const later = pending.enqueue("plugin", nextWork);
|
||||
lateFlush.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(nextWork).not.toHaveBeenCalled();
|
||||
firstSave.resolve();
|
||||
await Promise.all([first, coalesced]);
|
||||
expect(nextWork).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await later;
|
||||
expect(nextWork).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects every coalesced notification with the original failure and permits a later retry", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pending = new PendingConfigSaves();
|
||||
const failure = new Error("database write failed");
|
||||
const save = async () => {
|
||||
throw failure;
|
||||
};
|
||||
const first = pending.enqueue("plugin", save);
|
||||
const second = pending.enqueue("plugin", save);
|
||||
const outcomes = Promise.allSettled([first, second]);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(await outcomes).toEqual([
|
||||
{ status: "rejected", reason: failure },
|
||||
{ status: "rejected", reason: failure },
|
||||
]);
|
||||
const retry = pending.enqueue("plugin", async () => {}, Promise.resolve());
|
||||
await retry;
|
||||
});
|
||||
});
|
||||
@@ -20,10 +20,14 @@
|
||||
MODE_SHINY,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { normalizePath } from "@/deps";
|
||||
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import {
|
||||
HiddenFileSync,
|
||||
HIDDEN_FILE_SYNC_DISABLED_NOTICE,
|
||||
} from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import { getAutomaticSyncTargetFiles } from "./getAutomaticSyncTargetFiles.ts";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
@@ -139,6 +143,11 @@
|
||||
async function deleteData(data: IPluginDataExDisplay): Promise<boolean> {
|
||||
return await addOn.deleteData(data);
|
||||
}
|
||||
function checkAutomaticSyncAvailable() {
|
||||
if (addOnHiddenFileSync.isThisModuleEnabled()) return true;
|
||||
Logger(translateMessage(HIDDEN_FILE_SYNC_DISABLED_NOTICE), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
function askMode(evt: MouseEvent, title: string, key: string) {
|
||||
const menu = new Menu();
|
||||
menu.addItem((item) => item.setTitle(title).setIsLabel(true));
|
||||
@@ -149,6 +158,7 @@
|
||||
item.setTitle(`${getIcon(mode as SYNC_MODE)}:${TITLES[mode]}`)
|
||||
.onClick((e) => {
|
||||
if (mode === MODE_AUTOMATIC) {
|
||||
if (!checkAutomaticSyncAvailable()) return;
|
||||
askOverwriteModeForAutomatic(evt, key);
|
||||
} else {
|
||||
setMode(key, mode as SYNC_MODE);
|
||||
@@ -161,10 +171,11 @@
|
||||
menu.showAtMouseEvent(evt);
|
||||
}
|
||||
function applyAutomaticSync(key: string, direction: "pushForce" | "pullForce" | "safe") {
|
||||
if (!checkAutomaticSyncAvailable()) return;
|
||||
setMode(key, MODE_AUTOMATIC);
|
||||
const configDir = normalizePath(plugin.core.services.API.getSystemConfigDir());
|
||||
const files = (plugin.core.settings.pluginSyncExtendedSetting[key]?.files ?? []).map((e) => `${configDir}/${e}`);
|
||||
addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files);
|
||||
const files = getAutomaticSyncTargetFiles(key, configDir, plugin.core.settings.pluginSyncExtendedSetting);
|
||||
void addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files);
|
||||
}
|
||||
function askOverwriteModeForAutomatic(evt: MouseEvent, key: string) {
|
||||
const menu = new Menu();
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
export function getAutomaticSyncTargetFiles(
|
||||
key: string,
|
||||
configDir: string,
|
||||
entries: ObsidianLiveSyncSettings["pluginSyncExtendedSetting"]
|
||||
): string[] {
|
||||
const keys = key.startsWith("PLUGIN_ALL/")
|
||||
? [`PLUGIN_DATA/${key.substring("PLUGIN_ALL/".length)}`, `PLUGIN_MAIN/${key.substring("PLUGIN_ALL/".length)}`]
|
||||
: [key];
|
||||
const files = new Set(keys.flatMap((entryKey) => entries[entryKey]?.files ?? []));
|
||||
return [...files].map((file) => `${configDir}/${file}`);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MODE_AUTOMATIC, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { getAutomaticSyncTargetFiles } from "./getAutomaticSyncTargetFiles.ts";
|
||||
|
||||
function createEntries(): ObsidianLiveSyncSettings["pluginSyncExtendedSetting"] {
|
||||
return {
|
||||
"CONFIG/app.json": { key: "CONFIG/app.json", mode: MODE_AUTOMATIC, files: ["app.json"] },
|
||||
"PLUGIN_MAIN/example": {
|
||||
key: "PLUGIN_MAIN/example",
|
||||
mode: MODE_AUTOMATIC,
|
||||
files: ["plugins/example/main.js", "plugins/example/manifest.json"],
|
||||
},
|
||||
"PLUGIN_DATA/example": {
|
||||
key: "PLUGIN_DATA/example",
|
||||
mode: MODE_AUTOMATIC,
|
||||
files: ["plugins/example/data.json"],
|
||||
},
|
||||
"PLUGIN_ETC/example": {
|
||||
key: "PLUGIN_ETC/example",
|
||||
mode: MODE_AUTOMATIC,
|
||||
files: ["plugins/example/extra.json"],
|
||||
},
|
||||
"PLUGIN_MAIN/other": {
|
||||
key: "PLUGIN_MAIN/other",
|
||||
mode: MODE_AUTOMATIC,
|
||||
files: ["plugins/other/main.js"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("getAutomaticSyncTargetFiles", () => {
|
||||
it("initialises a single selected item in the current configuration directory", () => {
|
||||
expect(getAutomaticSyncTargetFiles("CONFIG/app.json", ".custom-config", createEntries())).toEqual([
|
||||
".custom-config/app.json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("initialises both saved child selections when the whole plug-in is selected", () => {
|
||||
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/example", ".obsidian", createEntries())).toEqual([
|
||||
".obsidian/plugins/example/data.json",
|
||||
".obsidian/plugins/example/main.js",
|
||||
".obsidian/plugins/example/manifest.json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains the available child files when the other child has no saved entry", () => {
|
||||
const entries = createEntries();
|
||||
delete entries["PLUGIN_DATA/example"];
|
||||
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/example", ".obsidian", entries)).toEqual([
|
||||
".obsidian/plugins/example/main.js",
|
||||
".obsidian/plugins/example/manifest.json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps an empty selection empty rather than including unrelated plug-ins", () => {
|
||||
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/absent", ".obsidian", createEntries())).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({}));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
|
||||
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
|
||||
vi.mock("./configureHiddenFileSyncMode.ts", () => ({ configureHiddenFileSyncMode: vi.fn() }));
|
||||
|
||||
import type { FilePath, UXStat } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { HiddenFileSync } from "./CmdHiddenFileSync";
|
||||
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
|
||||
|
||||
const path = ".obsidian/plugins/example/main.js" as FilePath;
|
||||
const content = "module.exports = class ExamplePlugin {};";
|
||||
const fixtureOwners: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
|
||||
|
||||
async function getText(entry: Parameters<typeof readContent>[0]) {
|
||||
const value = await readContent(entry);
|
||||
return typeof value === "string" ? value : new TextDecoder().decode(value);
|
||||
}
|
||||
|
||||
async function createHiddenFileSync() {
|
||||
const fixture = await createOptionalFileSyncDatabase();
|
||||
fixtureOwners.push(fixture);
|
||||
fixture.settings.usePluginSync = false;
|
||||
fixture.settings.syncInternalFilesTargetPatterns = "" as typeof fixture.settings.syncInternalFilesTargetPatterns;
|
||||
fixture.settings.syncInternalFilesIgnorePatterns = "" as typeof fixture.settings.syncInternalFilesIgnorePatterns;
|
||||
let fileContent = content;
|
||||
let stat: UXStat | null = {
|
||||
ctime: 1_000,
|
||||
mtime: 2_000,
|
||||
size: fileContent.length,
|
||||
type: "file",
|
||||
};
|
||||
const values = new Map<string, unknown>();
|
||||
const kvDB = {
|
||||
get: vi.fn(async <T>(key: string) => values.get(key) as T | undefined),
|
||||
set: vi.fn(async <T>(key: string, value: T) => {
|
||||
values.set(key, value);
|
||||
}),
|
||||
};
|
||||
const storageAccess = {
|
||||
statHidden: vi.fn(async () => stat),
|
||||
readHiddenFileAuto: vi.fn(async () => fileContent),
|
||||
writeHiddenFileAuto: vi.fn(async () => undefined),
|
||||
removeHidden: vi.fn(async () => true),
|
||||
isExistsIncludeHidden: vi.fn(async () => true),
|
||||
ensureDir: vi.fn(async () => undefined),
|
||||
triggerHiddenFile: vi.fn(async () => undefined),
|
||||
setFile(nextContent: string | null, mtime = 2_000) {
|
||||
fileContent = nextContent ?? "";
|
||||
stat = nextContent === null ? null : { ctime: 1_000, mtime, size: fileContent.length, type: "file" };
|
||||
},
|
||||
};
|
||||
const databaseService = { isDatabaseReady: vi.fn(() => true) };
|
||||
const appLifecycle = { isReady: vi.fn(() => false), isSuspended: vi.fn(() => true) };
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: {
|
||||
settings: fixture.settings,
|
||||
localDatabase: fixture.localDatabase,
|
||||
kvDB,
|
||||
storageAccess,
|
||||
services: {
|
||||
...fixture.services,
|
||||
database: databaseService,
|
||||
appLifecycle,
|
||||
path: {
|
||||
...fixture.services.path,
|
||||
markChangesAreSame: vi.fn(),
|
||||
unmarkChanges: vi.fn(),
|
||||
},
|
||||
vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) },
|
||||
},
|
||||
},
|
||||
cacheFileRegExps: new Map(),
|
||||
cacheCustomisationSyncIgnoredFiles: new Map(),
|
||||
_fileInfoLastProcessed: new Map(),
|
||||
_databaseInfoLastProcessed: new Map(),
|
||||
_fileInfoLastKnown: new Map(),
|
||||
semaphore: Semaphore(10),
|
||||
_log: vi.fn(),
|
||||
});
|
||||
return { fixture, hiddenFileSync, storageAccess, kvDB, databaseService, appLifecycle };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
|
||||
});
|
||||
|
||||
describe("Hidden File Sync optional save outcomes", () => {
|
||||
it("retries a failed database write when the storage fingerprint is unchanged", async () => {
|
||||
const { fixture, hiddenFileSync } = await createHiddenFileSync();
|
||||
await expect(hiddenFileSync.isTargetFile(path)).resolves.toBe(true);
|
||||
vi.spyOn(fixture.localDatabase, "putDBEntry").mockRejectedValueOnce(new Error("temporary database failure"));
|
||||
|
||||
await expect(hiddenFileSync.trackStorageFileModification(path)).resolves.toBe(false);
|
||||
await expect(hiddenFileSync.trackStorageFileModification(path)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.localDatabase.putDBEntry).toHaveBeenCalledTimes(2);
|
||||
const saved = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(saved).not.toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a failed strict write and retries the same fingerprint without delaying readiness", async () => {
|
||||
const { fixture, hiddenFileSync, appLifecycle } = await createHiddenFileSync();
|
||||
const putDBEntry = vi
|
||||
.spyOn(fixture.localDatabase, "putDBEntry")
|
||||
.mockRejectedValueOnce(new Error("temporary database failure"));
|
||||
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
|
||||
"temporary database failure"
|
||||
);
|
||||
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBeUndefined();
|
||||
|
||||
await expect(
|
||||
hiddenFileSync._processOptionalFileEventWithResult(path, {
|
||||
whenFlushRequested: new Promise<void>(() => undefined),
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
expect(putDBEntry).toHaveBeenCalledTimes(2);
|
||||
const saved = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(saved).not.toBe(false);
|
||||
if (saved) expect(await getText(saved)).toBe(content);
|
||||
expect(appLifecycle.isReady()).toBe(false);
|
||||
expect(appLifecycle.isSuspended()).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a false database-write result and can store a later edit after a same-content event", async () => {
|
||||
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
|
||||
const putDBEntry = vi
|
||||
.spyOn(fixture.localDatabase, "putDBEntry")
|
||||
.mockImplementationOnce(async () => false as never);
|
||||
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
|
||||
`Failed to store hidden file in the database: ${path}`
|
||||
);
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
const first = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(first).not.toBe(false);
|
||||
if (first) {
|
||||
const firstRevision = first._rev;
|
||||
hiddenFileSync._fileInfoLastProcessed.delete(path);
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
const unchanged = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(unchanged).not.toBe(false);
|
||||
if (unchanged) expect(unchanged._rev).toBe(firstRevision);
|
||||
}
|
||||
|
||||
storageAccess.setFile("module.exports = class UpdatedExamplePlugin {};", 3_000);
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
const updated = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(updated).not.toBe(false);
|
||||
if (updated) expect(await getText(updated)).toBe("module.exports = class UpdatedExamplePlugin {};");
|
||||
expect(putDBEntry).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("vetoes when the physical database is unavailable and ignores disabled or filtered events", async () => {
|
||||
const { fixture, hiddenFileSync, databaseService } = await createHiddenFileSync();
|
||||
|
||||
databaseService.isDatabaseReady.mockReturnValue(false);
|
||||
await expect(
|
||||
hiddenFileSync._processOptionalFileEventWithResult(path, {
|
||||
whenFlushRequested: new Promise<void>(() => undefined),
|
||||
})
|
||||
).resolves.toMatchObject({ kind: "veto", value: expect.any(String) });
|
||||
databaseService.isDatabaseReady.mockReturnValue(true);
|
||||
|
||||
fixture.settings.syncInternalFiles = false;
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "not-applicable",
|
||||
});
|
||||
fixture.settings.syncInternalFiles = true;
|
||||
fixture.settings.syncInternalFilesTargetPatterns =
|
||||
"^\\.obsidian/snippets/" as typeof fixture.settings.syncInternalFilesTargetPatterns;
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "not-applicable",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an absent replayed deletion as complete and leaves cache initialisation intact", async () => {
|
||||
const { hiddenFileSync, storageAccess, kvDB } = await createHiddenFileSync();
|
||||
storageAccess.setFile(null);
|
||||
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBe("0-0");
|
||||
const lifecycle = hiddenFileSync as unknown as {
|
||||
_everyOnDatabaseInitialized(showNotice: boolean): Promise<boolean>;
|
||||
};
|
||||
hiddenFileSync.core.settings.syncInternalFiles = false;
|
||||
await lifecycle._everyOnDatabaseInitialized(false);
|
||||
expect(kvDB.get).toHaveBeenCalledTimes(3);
|
||||
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBe("0-0");
|
||||
});
|
||||
|
||||
it("rejects actual deletion failures and completes duplicate deletions without another write", async () => {
|
||||
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
|
||||
await fixture.localDatabase.putDBEntry(optionalFileEntry(`i:${path}`, content));
|
||||
storageAccess.setFile(null);
|
||||
const putRaw = vi
|
||||
.spyOn(fixture.localDatabase, "putRaw")
|
||||
.mockRejectedValueOnce(new Error("temporary deletion failure"));
|
||||
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
|
||||
"temporary deletion failure"
|
||||
);
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
hiddenFileSync.resetLastProcessedFile([path]);
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
expect(putRaw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("recreates an empty hidden file when its database entry is logically deleted", async () => {
|
||||
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
|
||||
await fixture.localDatabase.putDBEntry(optionalFileEntry(`i:${path}`, ""));
|
||||
await expect(hiddenFileSync.deleteInternalFileOnDatabase(path)).resolves.toBe(true);
|
||||
const deletedBase = await fixture.localDatabase.getDBEntry(
|
||||
`i:${path}` as FilePath,
|
||||
undefined,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
);
|
||||
expect(deletedBase).not.toBe(false);
|
||||
if (deletedBase) expect(deletedBase.deleted).toBe(true);
|
||||
if (!deletedBase) throw new Error("The logically deleted test entry could not be loaded");
|
||||
vi.spyOn(fixture.localDatabase, "getDBEntry").mockResolvedValueOnce(deletedBase);
|
||||
|
||||
storageAccess.setFile("", 4_000);
|
||||
const putDBEntry = vi.spyOn(fixture.localDatabase, "putDBEntry");
|
||||
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
|
||||
expect(putDBEntry).toHaveBeenCalledTimes(1);
|
||||
const recreated = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(recreated).not.toBe(false);
|
||||
if (recreated) {
|
||||
expect(recreated.deleted).not.toBe(true);
|
||||
expect(await getText(recreated)).toBe("");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({}));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
|
||||
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
|
||||
vi.mock("./configureHiddenFileSyncMode.ts", () => ({ configureHiddenFileSyncMode: vi.fn() }));
|
||||
|
||||
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
|
||||
import { HiddenFileSync } from "./CmdHiddenFileSync";
|
||||
|
||||
const hiddenPath = ".obsidian/plugins/example/main.js" as FilePath;
|
||||
const prefixedPath = `i:${hiddenPath}`;
|
||||
const body = "module.exports = class ExamplePlugin {};";
|
||||
const allowList = "\\.(?:md|png|pdf)$";
|
||||
const fixtureOwners: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
|
||||
|
||||
async function hiddenFiles() {
|
||||
const fixture = await createOptionalFileSyncDatabase();
|
||||
fixtureOwners.push(fixture);
|
||||
fixture.settings.usePluginSync = false;
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: {
|
||||
settings: fixture.settings,
|
||||
services: {
|
||||
...fixture.services,
|
||||
vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) },
|
||||
},
|
||||
},
|
||||
cacheFileRegExps: new Map(),
|
||||
cacheCustomisationSyncIgnoredFiles: new Map(),
|
||||
});
|
||||
return { ...fixture, hiddenFileSync };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
|
||||
});
|
||||
|
||||
describe("issue #1272: Hidden File Sync with normal-file selectors", () => {
|
||||
it("stores a selected hidden file while the normal-file allow-list is set", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
|
||||
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(true);
|
||||
|
||||
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(prefixedPath, body))).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
const stored = await fixture.localDatabase.getDBEntry(prefixedPath as FilePath);
|
||||
expect(stored).not.toBe(false);
|
||||
if (stored) expect(await readContent(stored)).toBe(body);
|
||||
});
|
||||
|
||||
it("reads existing hidden-file content while the normal-file allow-list is set", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(prefixedPath, body))).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
const meta = await fixture.localDatabase.getDBEntryMeta(prefixedPath as FilePath);
|
||||
if (!meta) throw new Error("The seeded hidden-file Metadata is unavailable");
|
||||
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
|
||||
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(true);
|
||||
|
||||
const entry = await fixture.localDatabase.getDBEntryFromMeta(meta);
|
||||
expect(entry).not.toBe(false);
|
||||
if (entry) expect(await readContent(entry)).toBe(body);
|
||||
});
|
||||
|
||||
it("stores and reads the same hidden file when the normal-file allow-list is empty", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(prefixedPath, body))).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
const entry = await fixture.localDatabase.getDBEntry(prefixedPath as FilePath);
|
||||
expect(entry).not.toBe(false);
|
||||
if (entry) expect(await readContent(entry)).toBe(body);
|
||||
});
|
||||
|
||||
it("continues to apply the normal-file allow-list to ordinary files", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
|
||||
expect(fixture.localDatabase.isTargetFile("notes/example.md" as FilePath)).toBe(true);
|
||||
expect(fixture.localDatabase.isTargetFile("notes/example.js" as FilePath)).toBe(false);
|
||||
expect(fixture.localDatabase.isTargetFile("i:.obsidian/example.md" as FilePath)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps Customisation Sync documents usable with the same normal-file allow-list", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
|
||||
const path = "ix:device/PLUGIN_MAIN/example%main.js" as FilePath;
|
||||
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(path, body))).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
const entry = await fixture.localDatabase.getDBEntry(path);
|
||||
expect(entry).not.toBe(false);
|
||||
if (entry) expect(await readContent(entry)).toBe(body);
|
||||
});
|
||||
|
||||
it("keeps Hidden File Sync target and ignore patterns effective", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
fixture.settings.syncInternalFilesTargetPatterns =
|
||||
"^\\.obsidian/plugins/" as typeof fixture.settings.syncInternalFilesTargetPatterns;
|
||||
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(true);
|
||||
await expect(fixture.hiddenFileSync.isTargetFile(".obsidian/snippets/example.css" as FilePath)).resolves.toBe(
|
||||
false
|
||||
);
|
||||
fixture.settings.syncInternalFilesIgnorePatterns =
|
||||
"main\\.js$" as typeof fixture.settings.syncInternalFilesIgnorePatterns;
|
||||
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("compatibility: applies the normal-file ignore-list to hidden documents", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
|
||||
fixture.settings.syncIgnoreRegEx = "^\\.obsidian/" as typeof fixture.settings.syncIgnoreRegEx;
|
||||
expect(fixture.localDatabase.isTargetFile(prefixedPath as FilePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects hidden documents when Hidden File Sync is disabled", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
fixture.settings.syncInternalFiles = false;
|
||||
expect(fixture.localDatabase.isTargetFile(prefixedPath as FilePath)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,8 @@ 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 "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
||||
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
||||
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import { configureHiddenFileSyncMode, type ConfigureHiddenFileSyncResult } from "./configureHiddenFileSyncMode.ts";
|
||||
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
|
||||
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
|
||||
@@ -69,6 +71,9 @@ type HiddenFileInitialisationProgress = {
|
||||
const HIDDEN_FILE_NOTICE_GROUP = "hidden-file-changes";
|
||||
const HIDDEN_FILE_NOTICE_DURATION_MS = 20_000;
|
||||
|
||||
export const HIDDEN_FILE_SYNC_DISABLED_NOTICE =
|
||||
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.";
|
||||
|
||||
function getComparingMTime(
|
||||
doc: (MetaEntry | LoadedEntry | false) | UXFileInfo | UXStat | null | undefined,
|
||||
includeDeleted = false
|
||||
@@ -161,9 +166,7 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
// return true;
|
||||
// }
|
||||
private async _everyOnDatabaseInitialized(showNotice: boolean) {
|
||||
this._fileInfoLastProcessed = await autosaveCache(this.kvDB, "hidden-file-lastProcessed");
|
||||
this._databaseInfoLastProcessed = await autosaveCache(this.kvDB, "hidden-file-lastProcessed-database");
|
||||
this._fileInfoLastKnown = await autosaveCache(this.kvDB, "hidden-file-lastKnown");
|
||||
await this.ensureProcessedCaches();
|
||||
if (this.isThisModuleEnabled()) {
|
||||
if (this._fileInfoLastProcessed.size == 0 && this._fileInfoLastProcessed.size == 0) {
|
||||
this._log(`No cache found. Performing startup scan.`, LOG_LEVEL_VERBOSE);
|
||||
@@ -249,6 +252,26 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
return false;
|
||||
}
|
||||
|
||||
async _processOptionalFileEventWithResult(
|
||||
path: FilePath,
|
||||
_options?: OptionalFileEventOptions
|
||||
): Promise<HandlerOutcome<void, string>> {
|
||||
if (!this.isThisModuleEnabled()) return { kind: "not-applicable" };
|
||||
if (!(await this.isTargetFile(path))) return { kind: "not-applicable" };
|
||||
if (!this._isDatabaseReady()) {
|
||||
return { kind: "veto", value: "The local database is not ready" };
|
||||
}
|
||||
|
||||
await this.ensureProcessedCaches();
|
||||
if (!this.isThisModuleEnabled() || !(await this.isTargetFile(path))) {
|
||||
return { kind: "not-applicable" };
|
||||
}
|
||||
if (!(await this.trackStorageFileModificationStrict(path))) {
|
||||
return { kind: "not-applicable" };
|
||||
}
|
||||
return { kind: "completed" };
|
||||
}
|
||||
|
||||
_anyGetOptionalConflictCheckMethod(path: FilePathWithPrefix): Promise<boolean | "newer"> {
|
||||
if (isInternalMetadata(path)) {
|
||||
this.queueConflictCheck(path);
|
||||
@@ -317,6 +340,51 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
_fileInfoLastKnown!: MapLike<string, number>;
|
||||
_databaseInfoLastProcessed!: MapLike<string, string>;
|
||||
|
||||
private processedCacheStore: unknown;
|
||||
private processedCacheStoreReady = false;
|
||||
private processedCacheLoadStore: unknown;
|
||||
private processedCacheLoad?: Promise<void>;
|
||||
|
||||
private async ensureProcessedCaches() {
|
||||
const store = this.kvDB;
|
||||
if (!store) throw new Error("The Hidden File Sync cache store is unavailable");
|
||||
if (this.processedCacheStoreReady && this.processedCacheStore === store) return;
|
||||
if (this.processedCacheLoad && this.processedCacheLoadStore === store) {
|
||||
await this.processedCacheLoad;
|
||||
if (!this.processedCacheStoreReady || this.processedCacheStore !== this.kvDB) {
|
||||
throw new Error("The Hidden File Sync cache store changed while loading its processing state");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let load!: Promise<void>;
|
||||
load = Promise.all([
|
||||
autosaveCache<string, string>(store, "hidden-file-lastProcessed"),
|
||||
autosaveCache<string, string>(store, "hidden-file-lastProcessed-database"),
|
||||
autosaveCache<string, number>(store, "hidden-file-lastKnown"),
|
||||
])
|
||||
.then(([fileInfoLastProcessed, databaseInfoLastProcessed, fileInfoLastKnown]) => {
|
||||
if (this.processedCacheLoad !== load || this.kvDB !== store) return;
|
||||
this._fileInfoLastProcessed = fileInfoLastProcessed;
|
||||
this._databaseInfoLastProcessed = databaseInfoLastProcessed;
|
||||
this._fileInfoLastKnown = fileInfoLastKnown;
|
||||
this.processedCacheStore = store;
|
||||
this.processedCacheStoreReady = true;
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.processedCacheLoad === load) {
|
||||
this.processedCacheLoad = undefined;
|
||||
this.processedCacheLoadStore = undefined;
|
||||
}
|
||||
});
|
||||
this.processedCacheLoadStore = store;
|
||||
this.processedCacheLoad = load;
|
||||
await load;
|
||||
if (!this.processedCacheStoreReady || this.processedCacheStore !== this.kvDB) {
|
||||
throw new Error("The Hidden File Sync cache store changed while loading its processing state");
|
||||
}
|
||||
}
|
||||
|
||||
statToKey(stat: UXStat | null) {
|
||||
return `${stat?.mtime ?? 0}-${stat?.size ?? 0}`;
|
||||
}
|
||||
@@ -600,7 +668,6 @@ Offline Changed files: ${processFiles.length}`;
|
||||
this._log(`Hidden file:${path} is changed.`, LOG_LEVEL_VERBOSE);
|
||||
stat = cache.stat;
|
||||
}
|
||||
this.updateLastProcessedFile(path, stat!);
|
||||
const lastIsNotFound = !lastKey || lastKey.endsWith("-0-0");
|
||||
const nowIsNotFound = cache.deleted;
|
||||
const type = lastIsNotFound && nowIsNotFound ? "invalid" : nowIsNotFound ? "delete" : "modified";
|
||||
@@ -651,6 +718,27 @@ Offline Changed files: ${processFiles.length}`;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async trackStorageFileModificationStrict(path: FilePath): Promise<boolean> {
|
||||
return await this.serializedForEvent(path, async () => {
|
||||
if (!this.isThisModuleEnabled() || !(await this.isTargetFile(path))) return false;
|
||||
const stat = await this.core.storageAccess.statHidden(path);
|
||||
if (stat != null && stat.type != "file") return true;
|
||||
const key = await this.fileToStatKey(path, stat);
|
||||
if (this.getLastProcessedFileKey(path) === key) {
|
||||
this._log(`${path} Already processed.`, LOG_LEVEL_DEBUG);
|
||||
return true;
|
||||
}
|
||||
|
||||
const fileInfo = await this.loadFileWithInfo(path);
|
||||
if (fileInfo.deleted) {
|
||||
await this.deleteInternalFileOnDatabaseStrict(path);
|
||||
return true;
|
||||
}
|
||||
await this.storeInternalFileToDatabaseStrict(fileInfo);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// --> Conflict processing
|
||||
|
||||
// Keep one in-flight conflict check per path so repeated sync events do not close the active merge dialogue.
|
||||
@@ -1444,6 +1532,11 @@ Offline Changed files: ${files.length}`;
|
||||
initialisationProgress?: HiddenFileInitialisationProgress
|
||||
) {
|
||||
const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||
if (!this.isThisModuleEnabled()) {
|
||||
this._log($msg(HIDDEN_FILE_SYNC_DISABLED_NOTICE), logLevel);
|
||||
initialisationProgress?.done($msg("Hidden File Sync initialisation was not started."));
|
||||
return false;
|
||||
}
|
||||
const p = initialisationProgress ?? this._progress("[⚙ Initialise]\n", logLevel);
|
||||
// p.log("Resolving conflicts before starting...");
|
||||
// await this.resolveConflictOnInternalFiles();
|
||||
@@ -1494,12 +1587,25 @@ Offline Changed files: ${files.length}`;
|
||||
await this.scanAllDatabaseChanges(showChildNotices, true, false);
|
||||
}
|
||||
p.done();
|
||||
return true;
|
||||
}
|
||||
// <-- Initialization functions
|
||||
|
||||
// --> Storage To Database Functions
|
||||
|
||||
async __loadBaseSaveData(file: FilePath, includeContent = true): Promise<LoadedEntry | false> {
|
||||
return await this.loadBaseSaveData(file, includeContent, false);
|
||||
}
|
||||
|
||||
private async loadBaseSaveDataStrict(file: FilePath, includeContent = true): Promise<LoadedEntry | false> {
|
||||
return await this.loadBaseSaveData(file, includeContent, true);
|
||||
}
|
||||
|
||||
private async loadBaseSaveData(
|
||||
file: FilePath,
|
||||
includeContent: boolean,
|
||||
strict: boolean
|
||||
): Promise<LoadedEntry | false> {
|
||||
const prefixedFileName = addPrefix(file, ICHeader);
|
||||
const id = await this.path2id(prefixedFileName, ICHeader);
|
||||
try {
|
||||
@@ -1525,6 +1631,7 @@ Offline Changed files: ${files.length}`;
|
||||
return old;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (strict) throw ex;
|
||||
this._log(`Getting base save data failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
@@ -1552,6 +1659,18 @@ Offline Changed files: ${files.length}`;
|
||||
}
|
||||
|
||||
async storeInternalFileToDatabase(file: InternalFileInfo | UXFileInfo, forceWrite = false) {
|
||||
return await this.storeInternalFileToDatabaseCore(file, forceWrite, false);
|
||||
}
|
||||
|
||||
private async storeInternalFileToDatabaseStrict(file: InternalFileInfo | UXFileInfo, forceWrite = false) {
|
||||
return await this.storeInternalFileToDatabaseCore(file, forceWrite, true);
|
||||
}
|
||||
|
||||
private async storeInternalFileToDatabaseCore(
|
||||
file: InternalFileInfo | UXFileInfo,
|
||||
forceWrite: boolean,
|
||||
strict: boolean
|
||||
) {
|
||||
const storeFilePath = stripAllPrefixes(file.path);
|
||||
const storageFilePath = file.path;
|
||||
if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) {
|
||||
@@ -1565,9 +1684,11 @@ Offline Changed files: ${files.length}`;
|
||||
if (fileInfo.deleted) {
|
||||
throw new Error(`Hidden file:${storeFilePath} is deleted. This should not be occurred.`);
|
||||
}
|
||||
const baseData = await this.__loadBaseSaveData(storeFilePath, true);
|
||||
const baseData = strict
|
||||
? await this.loadBaseSaveDataStrict(storeFilePath, true)
|
||||
: await this.__loadBaseSaveData(storeFilePath, true);
|
||||
if (baseData === false) throw new Error("Failed to load base data");
|
||||
if (baseData._rev && !forceWrite) {
|
||||
if (baseData._rev && !baseData.deleted && !baseData._deleted && !forceWrite) {
|
||||
// Not newly created, we should check the content is actually modified.
|
||||
const isSame = await isDocContentSame(readAsBlob(baseData), fileInfo.body);
|
||||
if (isSame) {
|
||||
@@ -1592,11 +1713,15 @@ Offline Changed files: ${files.length}`;
|
||||
this.updateLastProcessed(storeFilePath, saveData, fileInfo.stat);
|
||||
}
|
||||
const success = ret && ret.ok;
|
||||
if (!success && strict) {
|
||||
throw new Error(`Failed to store hidden file in the database: ${storeFilePath}`);
|
||||
}
|
||||
this._log(`STORAGE --> DB:${storageFilePath}: (hidden) ${success ? "Done" : "Failed"}`);
|
||||
return success;
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE --> DB:${storageFilePath}: (hidden) Failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (strict) throw ex;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
@@ -1676,6 +1801,14 @@ Offline Changed files: ${files.length}`;
|
||||
}
|
||||
|
||||
async deleteInternalFileOnDatabase(filenameSrc: FilePath, forceWrite = false) {
|
||||
return await this.deleteInternalFileOnDatabaseCore(filenameSrc, forceWrite, false);
|
||||
}
|
||||
|
||||
private async deleteInternalFileOnDatabaseStrict(filenameSrc: FilePath, forceWrite = false) {
|
||||
return await this.deleteInternalFileOnDatabaseCore(filenameSrc, forceWrite, true);
|
||||
}
|
||||
|
||||
private async deleteInternalFileOnDatabaseCore(filenameSrc: FilePath, _forceWrite: boolean, strict: boolean) {
|
||||
const storeFilePath = filenameSrc;
|
||||
const storageFilePath = filenameSrc;
|
||||
const displayFileName = filenameSrc;
|
||||
@@ -1686,18 +1819,26 @@ Offline Changed files: ${files.length}`;
|
||||
}
|
||||
return await serialized("file-" + prefixedFileName, async () => {
|
||||
try {
|
||||
const baseData = await this.__loadBaseSaveData(storeFilePath, false);
|
||||
const baseData = strict
|
||||
? await this.loadBaseSaveDataStrict(storeFilePath, false)
|
||||
: await this.__loadBaseSaveData(storeFilePath, false);
|
||||
if (baseData === false) throw new Error("Failed to load base data during deleting");
|
||||
if (baseData._conflicts !== undefined) {
|
||||
for (const conflictRev of baseData._conflicts) {
|
||||
await this.localDatabase.removeRevision(baseData._id, conflictRev);
|
||||
const removed = await this.localDatabase.removeRevision(baseData._id, conflictRev);
|
||||
if (strict && removed === false) {
|
||||
throw new Error(`Failed to remove conflicted hidden-file revision ${conflictRev}`);
|
||||
}
|
||||
this._log(
|
||||
`STORAGE -x> DB: ${displayFileName}: (hidden) conflict removed ${baseData._rev} => ${conflictRev}`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
}
|
||||
}
|
||||
if (baseData.deleted) {
|
||||
const alreadyDeleted = strict
|
||||
? !baseData._rev || baseData.deleted || baseData._deleted
|
||||
: baseData.deleted;
|
||||
if (alreadyDeleted) {
|
||||
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) already deleted`, LOG_LEVEL_VERBOSE);
|
||||
this.updateLastProcessedDeletion(storeFilePath, baseData);
|
||||
return true;
|
||||
@@ -1718,11 +1859,13 @@ Offline Changed files: ${files.length}`;
|
||||
return true;
|
||||
} else {
|
||||
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) Failed`);
|
||||
if (strict) throw new Error(`Failed to delete hidden file from the database: ${storeFilePath}`);
|
||||
return false;
|
||||
}
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) Failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (strict) throw ex;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
@@ -1925,6 +2068,7 @@ Offline Changed files: ${files.length}`;
|
||||
|
||||
async configureHiddenFileSync(mode: OptionalSyncFeatureMode) {
|
||||
let initialisationProgress: HiddenFileInitialisationProgress | undefined;
|
||||
let initialised = false;
|
||||
let result: ConfigureHiddenFileSyncResult;
|
||||
try {
|
||||
result = await configureHiddenFileSyncMode(mode, {
|
||||
@@ -1954,7 +2098,7 @@ Offline Changed files: ${files.length}`;
|
||||
);
|
||||
},
|
||||
initialise: async (direction) => {
|
||||
await this.initialiseInternalFileSync(direction, true, false, initialisationProgress);
|
||||
initialised = await this.initialiseInternalFileSync(direction, true, false, initialisationProgress);
|
||||
initialisationProgress = undefined;
|
||||
},
|
||||
});
|
||||
@@ -1962,7 +2106,7 @@ Offline Changed files: ${files.length}`;
|
||||
initialisationProgress?.done("Failed");
|
||||
throw error;
|
||||
}
|
||||
if (result == "ignored" || result == "disabled") {
|
||||
if (result == "ignored" || result == "disabled" || !initialised) {
|
||||
return;
|
||||
}
|
||||
// this.plugin.settings.useAdvancedMode = true;
|
||||
@@ -2084,6 +2228,9 @@ Offline Changed files: ${files.length}`;
|
||||
// services.databaseEvents.handleOnDatabaseInitialisation(this._everyOnInitializeDatabase.bind(this));
|
||||
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
|
||||
services.fileProcessing.processOptionalFileEvent.addHandler(this._anyProcessOptionalFileEvent.bind(this));
|
||||
services.fileProcessing.processOptionalFileEventWithResult?.addHandler(
|
||||
this._processOptionalFileEventWithResult.bind(this)
|
||||
);
|
||||
services.conflict.getOptionalConflictCheckMethod.addHandler(this._anyGetOptionalConflictCheckMethod.bind(this));
|
||||
services.replication.processOptionalSynchroniseResult.addHandler(this._anyProcessOptionalSyncFiles.bind(this));
|
||||
services.setting.onRealiseSetting.addHandler(this._everyRealizeSettingSyncMode.bind(this));
|
||||
|
||||
@@ -99,6 +99,115 @@ function createHiddenRevisionOperation() {
|
||||
}
|
||||
|
||||
describe("HiddenFileSync configuration-change notices", () => {
|
||||
it.each(["pushForce", "pullForce", "safe"] as const)(
|
||||
"refuses %s initialisation before processing files while Hidden File Sync is disabled",
|
||||
async (direction) => {
|
||||
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||
const path = ".obsidian/example.json" as FilePath;
|
||||
const operations = {
|
||||
rebuildFromStorage: vi.fn(async () => [path]),
|
||||
rebuildFromDatabase: vi.fn(async () => []),
|
||||
rebuildMerging: vi.fn(async () => [path]),
|
||||
adoptCurrentStorageFilesAsProcessed: vi.fn(async () => undefined),
|
||||
adoptCurrentDatabaseFilesAsProcessed: vi.fn(async () => undefined),
|
||||
scanAllStorageChanges: vi.fn(async () => undefined),
|
||||
scanAllDatabaseChanges: vi.fn(async () => undefined),
|
||||
};
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
const settings = { syncInternalFiles: false };
|
||||
const createProgress = vi.fn(() => progress);
|
||||
const log = vi.fn();
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: { settings },
|
||||
_progress: createProgress,
|
||||
_log: log,
|
||||
...operations,
|
||||
});
|
||||
|
||||
const result = await hiddenFileSync.initialiseInternalFileSync(direction, true, [path]);
|
||||
|
||||
for (const operation of Object.values(operations)) {
|
||||
expect(operation).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(result).toBe(false);
|
||||
expect(settings.syncInternalFiles).toBe(false);
|
||||
expect(createProgress).not.toHaveBeenCalled();
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("Hidden File Sync is disabled"), LOG_LEVEL_NOTICE);
|
||||
expect(progress.done).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it("finishes an existing preparation Notice as not started when initialisation is disabled", async () => {
|
||||
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||
const createProgress = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: { settings: { syncInternalFiles: false } },
|
||||
_progress: createProgress,
|
||||
_log: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await hiddenFileSync.initialiseInternalFileSync("safe", true, false, progress);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(createProgress).not.toHaveBeenCalled();
|
||||
expect(progress.done).toHaveBeenCalledExactlyOnceWith("Hidden File Sync initialisation was not started.");
|
||||
});
|
||||
|
||||
it("does not apply a stored deletion when initialisation is requested while disabled", async () => {
|
||||
const path = ".obsidian/example.json" as FilePath;
|
||||
const deleted: MetaEntry = {
|
||||
_id: "i:example" as DocumentID,
|
||||
_rev: "2-deleted",
|
||||
path: `i:${path}` as FilePathWithPrefix,
|
||||
type: "plain",
|
||||
ctime: 1,
|
||||
mtime: 10,
|
||||
size: 0,
|
||||
deleted: true,
|
||||
children: [],
|
||||
eden: {},
|
||||
};
|
||||
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||
const deleteFile = vi.fn(async () => "OK" as const);
|
||||
const updateDeletion = vi.fn();
|
||||
const getFiles = vi.fn(async () => [deleted]);
|
||||
const processedStorage = new Map([[path, "storage-processed"]]);
|
||||
const processedDatabase = new Map([[path, "database-processed"]]);
|
||||
const resetProcessed = vi.fn(() => processedDatabase.clear());
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: {
|
||||
settings: { syncInternalFiles: false },
|
||||
services: { vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) } },
|
||||
},
|
||||
_progress: vi.fn(() => progress),
|
||||
_log: vi.fn(),
|
||||
_verbose: vi.fn(),
|
||||
_fileInfoLastProcessed: processedStorage,
|
||||
_databaseInfoLastProcessed: processedDatabase,
|
||||
getAllDatabaseFiles: getFiles,
|
||||
getPath: (doc: MetaEntry) => doc.path,
|
||||
resetLastProcessedDatabase: resetProcessed,
|
||||
serializedForEvent: async (_path: unknown, operation: () => Promise<unknown>) => await operation(),
|
||||
__deleteFile: deleteFile,
|
||||
updateLastProcessedDeletion: updateDeletion,
|
||||
adoptCurrentStorageFilesAsProcessed: vi.fn(),
|
||||
adoptCurrentDatabaseFilesAsProcessed: vi.fn(),
|
||||
scanAllStorageChanges: vi.fn(),
|
||||
scanAllDatabaseChanges: vi.fn(),
|
||||
});
|
||||
|
||||
await hiddenFileSync.initialiseInternalFileSync("pullForce", true);
|
||||
|
||||
expect(deleteFile).not.toHaveBeenCalled();
|
||||
expect(updateDeletion).not.toHaveBeenCalled();
|
||||
expect(getFiles).not.toHaveBeenCalled();
|
||||
expect(resetProcessed).not.toHaveBeenCalled();
|
||||
expect(processedStorage).toEqual(new Map([[path, "storage-processed"]]));
|
||||
expect(processedDatabase).toEqual(new Map([[path, "database-processed"]]));
|
||||
});
|
||||
|
||||
it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => {
|
||||
const commands: Array<{
|
||||
id: string;
|
||||
@@ -136,6 +245,13 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
expect(command?.checkCallback?.(true)).toBe(false);
|
||||
}
|
||||
|
||||
settings.useAdvancedMode = true;
|
||||
for (const commandId of commandIds) {
|
||||
const command = commands.find(({ id }) => id === commandId);
|
||||
expect(command?.checkCallback?.(true)).toBe(false);
|
||||
expect(command?.checkCallback?.(false)).toBe(false);
|
||||
}
|
||||
|
||||
settings.syncInternalFiles = true;
|
||||
settings.useAdvancedMode = true;
|
||||
for (const commandId of commandIds) {
|
||||
@@ -252,6 +368,7 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
const scanAllDatabaseChanges = vi.fn(async () => undefined);
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: { settings: { syncInternalFiles: true } },
|
||||
_progress: vi.fn(() => progress),
|
||||
rebuildMerging,
|
||||
adoptCurrentStorageFilesAsProcessed,
|
||||
@@ -260,14 +377,83 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
scanAllDatabaseChanges,
|
||||
});
|
||||
|
||||
await hiddenFileSync.initialiseInternalFileSync("safe", true);
|
||||
const result = await hiddenFileSync.initialiseInternalFileSync("safe", true);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(rebuildMerging).toHaveBeenCalledWith(false, false);
|
||||
expect(scanAllStorageChanges).toHaveBeenCalledWith(false, true, false);
|
||||
expect(scanAllDatabaseChanges).toHaveBeenCalledWith(false, true, false);
|
||||
expect(progress.done).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(["pushForce", "pullForce", "safe"] as const)(
|
||||
"retains the selected files and completes %s initialisation when enabled",
|
||||
async (direction) => {
|
||||
const files = [".obsidian/plugins/example/main.js", ".obsidian/plugins/example/data.json"] as FilePath[];
|
||||
const entries = files.map((path) => ({ path: `i:${path}` as FilePathWithPrefix }));
|
||||
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||
const rebuildFromStorage = vi.fn(async () => files);
|
||||
const rebuildFromDatabase = vi.fn(async () => entries);
|
||||
const rebuildMerging = vi.fn(async () => files);
|
||||
const adoptStorage = vi.fn();
|
||||
const adoptDatabase = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: { settings: { syncInternalFiles: true } },
|
||||
_progress: vi.fn(() => progress),
|
||||
getPath: (entry: { path: FilePathWithPrefix }) => entry.path,
|
||||
rebuildFromStorage,
|
||||
rebuildFromDatabase,
|
||||
rebuildMerging,
|
||||
adoptCurrentStorageFilesAsProcessed: adoptStorage,
|
||||
adoptCurrentDatabaseFilesAsProcessed: adoptDatabase,
|
||||
scanAllStorageChanges: vi.fn(),
|
||||
scanAllDatabaseChanges: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await hiddenFileSync.initialiseInternalFileSync(direction, true, files);
|
||||
|
||||
expect(result).toBe(true);
|
||||
if (direction === "pushForce") {
|
||||
expect(rebuildFromStorage).toHaveBeenCalledExactlyOnceWith(false, files, false);
|
||||
expect(rebuildFromDatabase).not.toHaveBeenCalled();
|
||||
expect(rebuildMerging).not.toHaveBeenCalled();
|
||||
} else if (direction === "pullForce") {
|
||||
expect(rebuildFromDatabase).toHaveBeenCalledExactlyOnceWith(false, files, false);
|
||||
expect(rebuildFromStorage).not.toHaveBeenCalled();
|
||||
expect(rebuildMerging).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(rebuildMerging).toHaveBeenCalledExactlyOnceWith(false, files);
|
||||
expect(rebuildFromStorage).not.toHaveBeenCalled();
|
||||
expect(rebuildFromDatabase).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(adoptStorage).toHaveBeenCalledExactlyOnceWith(files);
|
||||
expect(adoptDatabase).toHaveBeenCalledExactlyOnceWith(files);
|
||||
expect(progress.done).toHaveBeenCalledOnce();
|
||||
}
|
||||
);
|
||||
|
||||
it("does not report completion when the initialisation entry point refuses to start", async () => {
|
||||
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
|
||||
await handlers.enable();
|
||||
await handlers.initialise("safe");
|
||||
return "enabled";
|
||||
});
|
||||
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||
const log = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: { services: { setting: { applyPartial: vi.fn() } } },
|
||||
_progress: vi.fn(() => progress),
|
||||
_log: log,
|
||||
initialiseInternalFileSync: vi.fn(async () => false),
|
||||
});
|
||||
|
||||
await hiddenFileSync.configureHiddenFileSync("MERGE");
|
||||
|
||||
expect(log).not.toHaveBeenCalledWith("Hidden File Sync initialisation completed.", expect.any(Number));
|
||||
});
|
||||
|
||||
it("retirement guard: does not restore separate gathering and restart Notices", async () => {
|
||||
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
|
||||
await handlers.enable();
|
||||
@@ -286,7 +472,7 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
const applyPartial = vi.fn(async () => {
|
||||
events.push("apply-settings");
|
||||
});
|
||||
const initialiseInternalFileSync = vi.fn(async () => undefined);
|
||||
const initialiseInternalFileSync = vi.fn(async () => true);
|
||||
const log = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { ValueStoreError } from "@vrtmrz/value-store";
|
||||
import { AutoAccepting, DEFAULT_SETTINGS, type P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
AcceptedStatus,
|
||||
@@ -33,6 +34,14 @@
|
||||
let settings = $state<P2PSyncSetting>(initialSettings);
|
||||
|
||||
let deviceName = $state<string>("");
|
||||
let deviceNameReady = $state(false);
|
||||
let dialogStatusReady = $state(false);
|
||||
type StorageErrorKey = "deviceNameLoad" | "dialogStatusLoad" | "deviceNameWrite" | "dialogStatusWrite";
|
||||
let storageErrors = $state<Partial<Record<StorageErrorKey, string>>>({});
|
||||
let mounted = false;
|
||||
let deviceNameLoadSequence = 0;
|
||||
let deviceNameWriteQueue: Promise<void> = Promise.resolve();
|
||||
let dialogStatusWriteQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
let eP2PEnabled = $state<boolean>(initialSettings.P2P_Enabled);
|
||||
let eRelay = $state<string>(initialSettings.P2P_relays);
|
||||
@@ -68,6 +77,7 @@
|
||||
);
|
||||
|
||||
async function saveAndApply() {
|
||||
const savedDeviceName = eDeviceName;
|
||||
// const newSettings = {
|
||||
// ...currentSettings(),
|
||||
// P2P_Enabled: eP2PEnabled,
|
||||
@@ -92,8 +102,39 @@
|
||||
},
|
||||
true
|
||||
);
|
||||
services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, eDeviceName);
|
||||
deviceName = eDeviceName;
|
||||
try {
|
||||
await queueDeviceNameWrite(savedDeviceName);
|
||||
if (!mounted) return;
|
||||
deviceName = savedDeviceName;
|
||||
clearStorageError("deviceNameWrite");
|
||||
} catch (error) {
|
||||
setStorageError("deviceNameWrite", error);
|
||||
}
|
||||
}
|
||||
|
||||
function setStorageError(key: StorageErrorKey, error: unknown) {
|
||||
if (mounted) {
|
||||
storageErrors = {
|
||||
...storageErrors,
|
||||
[key]: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function clearStorageError(key: StorageErrorKey) {
|
||||
if (!mounted) return;
|
||||
const nextErrors = { ...storageErrors };
|
||||
delete nextErrors[key];
|
||||
storageErrors = nextErrors;
|
||||
}
|
||||
|
||||
function queueDeviceNameWrite(name: string): Promise<void> {
|
||||
const write = deviceNameWriteQueue.catch(() => undefined).then(async () => {
|
||||
if (!mounted) return;
|
||||
await services.config.setValue(SETTING_KEY_P2P_DEVICE_NAME, name);
|
||||
});
|
||||
deviceNameWriteQueue = write;
|
||||
return write;
|
||||
}
|
||||
async function revert() {
|
||||
eP2PEnabled = settings.P2P_Enabled;
|
||||
@@ -110,10 +151,7 @@
|
||||
let replicatorInfo = $state<P2PReplicatorStatus | undefined>(undefined);
|
||||
const applyLoadSettings = (d: P2PSyncSetting, force: boolean) => {
|
||||
if (force) {
|
||||
const initDeviceName =
|
||||
services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) ?? services.vault.getVaultName();
|
||||
deviceName = initDeviceName;
|
||||
eDeviceName = initDeviceName;
|
||||
void loadDeviceName(true).catch(() => undefined);
|
||||
}
|
||||
const { P2P_relays, P2P_roomID, P2P_passphrase, P2P_AppID, P2P_AutoAccepting } = d;
|
||||
if (force || !isP2PEnabledModified) eP2PEnabled = d.P2P_Enabled;
|
||||
@@ -129,6 +167,7 @@
|
||||
settings = d;
|
||||
};
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
const r = events.onEvent("setting-saved", async (d) => {
|
||||
applyLoadSettings(d, false);
|
||||
closeServer();
|
||||
@@ -143,15 +182,52 @@
|
||||
const r3 = events.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
|
||||
replicatorInfo = status;
|
||||
});
|
||||
applyLoadSettings(currentSettings(), true);
|
||||
applyLoadSettings(currentSettings(), false);
|
||||
void loadInitialPreferences().catch(() => undefined);
|
||||
host.p2p.diagnostics.requestStatus();
|
||||
return () => {
|
||||
mounted = false;
|
||||
r();
|
||||
rx();
|
||||
r2();
|
||||
r3();
|
||||
};
|
||||
});
|
||||
|
||||
async function loadInitialPreferences() {
|
||||
const deviceNameLoad = loadDeviceName(true).catch(() => undefined);
|
||||
const dialogStatusLoad = readDialogStatus().then(
|
||||
(dialogStatus) => {
|
||||
if (!mounted) return;
|
||||
isNoticeOpened = dialogStatus.notice ?? true;
|
||||
isSettingOpened = dialogStatus.setting ?? true;
|
||||
dialogStatusReady = true;
|
||||
clearStorageError("dialogStatusLoad");
|
||||
},
|
||||
(error: unknown) => {
|
||||
setStorageError("dialogStatusLoad", error);
|
||||
}
|
||||
);
|
||||
await Promise.all([deviceNameLoad, dialogStatusLoad]);
|
||||
}
|
||||
|
||||
async function loadDeviceName(force: boolean) {
|
||||
const request = ++deviceNameLoadSequence;
|
||||
try {
|
||||
const savedDeviceName = await services.config.getValue(SETTING_KEY_P2P_DEVICE_NAME, "");
|
||||
if (!mounted || request !== deviceNameLoadSequence) return;
|
||||
const initialDeviceName = savedDeviceName;
|
||||
if (force || !isDeviceNameModified) {
|
||||
deviceName = initialDeviceName;
|
||||
eDeviceName = initialDeviceName;
|
||||
}
|
||||
deviceNameReady = true;
|
||||
clearStorageError("deviceNameLoad");
|
||||
} catch (error) {
|
||||
if (mounted && request === deviceNameLoadSequence) setStorageError("deviceNameLoad", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
let isConnected = $derived.by(() => {
|
||||
return serverInfo?.isConnected ?? false;
|
||||
});
|
||||
@@ -234,26 +310,42 @@
|
||||
}
|
||||
|
||||
const initialDialogStatusKey = `p2p-dialog-status`;
|
||||
const getDialogStatus = () => {
|
||||
const readDialogStatus = async (): Promise<{ notice?: boolean; setting?: boolean }> => {
|
||||
try {
|
||||
const initialDialogStatus = JSON.parse(services.config.getSmallConfig(initialDialogStatusKey) ?? "{}") as {
|
||||
notice?: boolean;
|
||||
setting?: boolean;
|
||||
};
|
||||
return initialDialogStatus;
|
||||
} catch {
|
||||
const initialDialogStatus = await services.config.getObject(initialDialogStatusKey);
|
||||
if (typeof initialDialogStatus !== "object" || initialDialogStatus === null || Array.isArray(initialDialogStatus)) {
|
||||
return {};
|
||||
}
|
||||
const status = initialDialogStatus as Record<string, unknown>;
|
||||
return {
|
||||
...(typeof status.notice === "boolean" ? { notice: status.notice } : {}),
|
||||
...(typeof status.setting === "boolean" ? { setting: status.setting } : {}),
|
||||
};
|
||||
const initialDialogStatus = getDialogStatus();
|
||||
let isNoticeOpened = $state<boolean>(initialDialogStatus.notice ?? true);
|
||||
let isSettingOpened = $state<boolean>(initialDialogStatus.setting ?? true);
|
||||
} catch (error) {
|
||||
if (error instanceof ValueStoreError && error.code === "invalid-data") {
|
||||
// Invalid optional UI state is recovered with the historical defaults.
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
let isNoticeOpened = $state<boolean>(true);
|
||||
let isSettingOpened = $state<boolean>(true);
|
||||
$effect(() => {
|
||||
if (!mounted || !dialogStatusReady) return;
|
||||
const dialogStatus = {
|
||||
notice: isNoticeOpened,
|
||||
setting: isSettingOpened,
|
||||
};
|
||||
services.config.setSmallConfig(initialDialogStatusKey, JSON.stringify(dialogStatus));
|
||||
const write = dialogStatusWriteQueue.catch(() => undefined).then(async () => {
|
||||
if (!mounted) return;
|
||||
await services.config.setObject(initialDialogStatusKey, dialogStatus);
|
||||
});
|
||||
dialogStatusWriteQueue = write;
|
||||
void write.then(
|
||||
() => clearStorageError("dialogStatusWrite"),
|
||||
(error: unknown) => setStorageError("dialogStatusWrite", error)
|
||||
);
|
||||
});
|
||||
let isObsidian = $derived.by(() => {
|
||||
return services.API.getPlatform() === "obsidian";
|
||||
@@ -261,6 +353,9 @@
|
||||
</script>
|
||||
|
||||
<article>
|
||||
{#each Object.values(storageErrors) as storageError}
|
||||
<p role="alert">{storageError}</p>
|
||||
{/each}
|
||||
<h1>{_msg("Peer to Peer Replicator")}</h1>
|
||||
<details bind:open={isNoticeOpened}>
|
||||
<summary>{_msg("P2P.Note.Summary")}</summary>
|
||||
@@ -345,6 +440,7 @@
|
||||
type="text"
|
||||
placeholder="iphone-16"
|
||||
bind:value={eDeviceName}
|
||||
disabled={!deviceNameReady}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
@@ -383,7 +479,7 @@
|
||||
</tr> -->
|
||||
</tbody>
|
||||
</table>
|
||||
<button disabled={!isAnyModified} class="button mod-cta" onclick={saveAndApply}
|
||||
<button disabled={!isAnyModified || !deviceNameReady} class="button mod-cta" onclick={saveAndApply}
|
||||
>{_msg("Save and Apply")}</button
|
||||
>
|
||||
<button disabled={!isAnyModified} class="button" onclick={revert}>{_msg("Revert changes")}</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ItemView, Notice, Setting, type WorkspaceLeaf } from "@/deps";
|
||||
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
||||
import {
|
||||
REVIEW_HARNESS_SCENARIOS,
|
||||
type ReviewHarnessScenarioId,
|
||||
@@ -101,7 +102,7 @@ export class ReviewHarnessView extends ItemView {
|
||||
true
|
||||
);
|
||||
this.addActionButton(actions, "Restart and return", "review-harness-restart", () =>
|
||||
this.controller.prepareCompatibilityReviewRestart()
|
||||
this.controller.prepareCompatibilityReviewRestart().catch((error) => Logger(error, LOG_LEVEL_NOTICE))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@ export interface ReviewHarnessRuntime {
|
||||
runVaultRoundTrip(): Promise<ReviewHarnessScenarioResult>;
|
||||
runIdBenchmark(): Promise<ReviewHarnessScenarioResult>;
|
||||
runFileContentHashBenchmark(): Promise<ReviewHarnessScenarioResult>;
|
||||
readContinuation(): string | null;
|
||||
writeContinuation(value: string): void;
|
||||
deleteContinuation(): void;
|
||||
readContinuation(): Promise<string | null>;
|
||||
writeContinuation(value: string): Promise<void>;
|
||||
deleteContinuation(): Promise<void>;
|
||||
restart(): void;
|
||||
reportError(error: unknown): void;
|
||||
copyText(value: string): Promise<void>;
|
||||
@@ -68,6 +68,7 @@ export class ReviewHarnessController {
|
||||
private current: ReviewHarnessScenarioId | null = null;
|
||||
private resumedRequestId: string | null = null;
|
||||
private continuationError: string | null = null;
|
||||
private continuationConsumption: Promise<void> | undefined;
|
||||
|
||||
constructor(private readonly runtime: ReviewHarnessRuntime) {}
|
||||
|
||||
@@ -102,10 +103,20 @@ export class ReviewHarnessController {
|
||||
};
|
||||
}
|
||||
|
||||
consumeContinuation(): void {
|
||||
const serialised = this.runtime.readContinuation();
|
||||
consumeContinuation(): Promise<void> {
|
||||
if (this.continuationConsumption) return this.continuationConsumption;
|
||||
const consumption = this.consumeContinuationFromStore();
|
||||
const trackedConsumption = consumption.finally(() => {
|
||||
if (this.continuationConsumption === trackedConsumption) this.continuationConsumption = undefined;
|
||||
});
|
||||
this.continuationConsumption = trackedConsumption;
|
||||
return trackedConsumption;
|
||||
}
|
||||
|
||||
private async consumeContinuationFromStore(): Promise<void> {
|
||||
const serialised = await this.runtime.readContinuation();
|
||||
if (!serialised) return;
|
||||
this.runtime.deleteContinuation();
|
||||
await this.runtime.deleteContinuation();
|
||||
const parsed = parsePendingReviewRun(serialised);
|
||||
if (!parsed.pendingRun) {
|
||||
this.continuationError = parsed.error ?? "The Review Harness continuation was invalid.";
|
||||
@@ -232,7 +243,7 @@ export class ReviewHarnessController {
|
||||
this.record("scenario-failed", id);
|
||||
}
|
||||
|
||||
prepareCompatibilityReviewRestart(): void {
|
||||
async prepareCompatibilityReviewRestart(): Promise<void> {
|
||||
const requestedAt = this.runtime.now().toISOString();
|
||||
const pending = {
|
||||
formatVersion: 1,
|
||||
@@ -241,7 +252,7 @@ export class ReviewHarnessController {
|
||||
stage: "awaiting-restart",
|
||||
requestedAt,
|
||||
} as const;
|
||||
this.runtime.writeContinuation(JSON.stringify(pending));
|
||||
await this.runtime.writeContinuation(JSON.stringify(pending));
|
||||
this.results["compatibility-review"] = {
|
||||
status: "waiting-for-user",
|
||||
detail: "Restart requested. The one-shot continuation will be removed before the review resumes.",
|
||||
|
||||
@@ -90,14 +90,14 @@ function createRuntime(): ReviewHarnessRuntime & {
|
||||
detail: "File content hash measurements completed.",
|
||||
observations: ["SHA-256, 4 KiB: median=1.00 ms; range=1.00–1.00 ms"],
|
||||
})),
|
||||
readContinuation() {
|
||||
async readContinuation() {
|
||||
return this.continuation;
|
||||
},
|
||||
writeContinuation(value) {
|
||||
async writeContinuation(value) {
|
||||
this.events.push("write");
|
||||
this.continuation = value;
|
||||
},
|
||||
deleteContinuation() {
|
||||
async deleteContinuation() {
|
||||
this.events.push("delete");
|
||||
this.continuation = null;
|
||||
},
|
||||
@@ -251,7 +251,7 @@ describe("ReviewHarnessController", () => {
|
||||
expect(controller.snapshot().running).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes a one-shot continuation before exposing the resumed guided step", () => {
|
||||
it("deletes a one-shot continuation before exposing the resumed guided step", async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.continuation = JSON.stringify({
|
||||
formatVersion: 1,
|
||||
@@ -262,7 +262,7 @@ describe("ReviewHarnessController", () => {
|
||||
});
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
controller.consumeContinuation();
|
||||
await controller.consumeContinuation();
|
||||
|
||||
expect(runtime.events).toEqual(["delete"]);
|
||||
expect(controller.snapshot().results["compatibility-review"]).toMatchObject({
|
||||
@@ -271,7 +271,7 @@ describe("ReviewHarnessController", () => {
|
||||
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
|
||||
});
|
||||
|
||||
it("does not copy rejected continuation values into the report", () => {
|
||||
it("does not copy rejected continuation values into the report", async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.continuation = JSON.stringify({
|
||||
formatVersion: 1,
|
||||
@@ -282,7 +282,7 @@ describe("ReviewHarnessController", () => {
|
||||
});
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
controller.consumeContinuation();
|
||||
await controller.consumeContinuation();
|
||||
|
||||
expect(controller.snapshot().continuationError).toContain("private-vault");
|
||||
expect(controller.snapshot().results["compatibility-review"].detail).toBe(
|
||||
@@ -292,7 +292,7 @@ describe("ReviewHarnessController", () => {
|
||||
expect(controller.createReport()).not.toContain("secret-note.md");
|
||||
});
|
||||
|
||||
it("does not accept an injected request ID which could enter the report transcript", () => {
|
||||
it("does not accept an injected request ID which could enter the report transcript", async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.continuation = JSON.stringify({
|
||||
formatVersion: 1,
|
||||
@@ -303,18 +303,18 @@ describe("ReviewHarnessController", () => {
|
||||
});
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
controller.consumeContinuation();
|
||||
await controller.consumeContinuation();
|
||||
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("failed");
|
||||
expect(controller.createReport()).not.toContain("private-vault");
|
||||
expect(controller.createReport()).not.toContain("secret-note.md");
|
||||
});
|
||||
|
||||
it("persists the fixed continuation before requesting restart", () => {
|
||||
it("persists the fixed continuation before requesting restart", async () => {
|
||||
const runtime = createRuntime();
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
controller.prepareCompatibilityReviewRestart();
|
||||
await controller.prepareCompatibilityReviewRestart();
|
||||
|
||||
expect(runtime.events).toEqual(["write", "restart"]);
|
||||
expect(JSON.parse(runtime.continuation!)).toMatchObject({
|
||||
@@ -324,6 +324,135 @@ describe("ReviewHarnessController", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for the continuation save before requesting restart", async () => {
|
||||
const runtime = createRuntime();
|
||||
let markSaveStarted!: () => void;
|
||||
const saveStarted = new Promise<void>((resolve) => {
|
||||
markSaveStarted = resolve;
|
||||
});
|
||||
let finishSave!: () => void;
|
||||
const saving = new Promise<void>((resolve) => {
|
||||
finishSave = resolve;
|
||||
});
|
||||
runtime.writeContinuation = vi.fn(async (value) => {
|
||||
runtime.events.push("write-started");
|
||||
markSaveStarted();
|
||||
await saving;
|
||||
runtime.continuation = value;
|
||||
runtime.events.push("write-saved");
|
||||
});
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
const preparing = controller.prepareCompatibilityReviewRestart();
|
||||
await saveStarted;
|
||||
|
||||
expect(runtime.events).toEqual(["write-started"]);
|
||||
expect(runtime.restart).not.toHaveBeenCalled();
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
|
||||
|
||||
finishSave();
|
||||
await preparing;
|
||||
|
||||
expect(runtime.events).toEqual(["write-started", "write-saved", "restart"]);
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
|
||||
});
|
||||
|
||||
it("keeps the guided review retryable when saving its continuation fails", async () => {
|
||||
const runtime = createRuntime();
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
await controller.runScenario("compatibility-review");
|
||||
runtime.writeContinuation = vi.fn(async () => {
|
||||
runtime.events.push("write-failed");
|
||||
throw new Error("continuation save failed");
|
||||
});
|
||||
|
||||
await expect(controller.prepareCompatibilityReviewRestart()).rejects.toThrow("continuation save failed");
|
||||
|
||||
expect(runtime.events).toEqual(["write-failed"]);
|
||||
expect(runtime.restart).not.toHaveBeenCalled();
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
|
||||
});
|
||||
|
||||
it("waits for one continuation deletion before exposing it and coalesces concurrent consumption", async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.continuation = JSON.stringify({
|
||||
formatVersion: 1,
|
||||
requestId: "compatibility-review-2026-07-18T11:59:00.000Z",
|
||||
scenarioId: "compatibility-review",
|
||||
stage: "awaiting-restart",
|
||||
requestedAt: "2026-07-18T11:59:00.000Z",
|
||||
});
|
||||
let markDeleteStarted!: () => void;
|
||||
const deleteStarted = new Promise<void>((resolve) => {
|
||||
markDeleteStarted = resolve;
|
||||
});
|
||||
let finishDelete!: () => void;
|
||||
const deleting = new Promise<void>((resolve) => {
|
||||
finishDelete = resolve;
|
||||
});
|
||||
runtime.readContinuation = vi.fn(async () => {
|
||||
runtime.events.push("read");
|
||||
return runtime.continuation;
|
||||
});
|
||||
runtime.deleteContinuation = vi.fn(async () => {
|
||||
runtime.events.push("delete-started");
|
||||
markDeleteStarted();
|
||||
await deleting;
|
||||
runtime.continuation = null;
|
||||
runtime.events.push("delete-finished");
|
||||
});
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
const firstConsumption = controller.consumeContinuation();
|
||||
const duplicateConsumption = controller.consumeContinuation();
|
||||
await deleteStarted;
|
||||
|
||||
expect(duplicateConsumption).toBe(firstConsumption);
|
||||
expect(runtime.events).toEqual(["read", "delete-started"]);
|
||||
expect(controller.snapshot().resumedRequestId).toBeNull();
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
|
||||
|
||||
finishDelete();
|
||||
await firstConsumption;
|
||||
|
||||
expect(runtime.events).toEqual(["read", "delete-started", "delete-finished"]);
|
||||
expect(runtime.readContinuation).toHaveBeenCalledOnce();
|
||||
expect(runtime.deleteContinuation).toHaveBeenCalledOnce();
|
||||
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
|
||||
});
|
||||
|
||||
it("keeps a failed continuation deletion available for retry", async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.continuation = JSON.stringify({
|
||||
formatVersion: 1,
|
||||
requestId: "compatibility-review-2026-07-18T11:59:00.000Z",
|
||||
scenarioId: "compatibility-review",
|
||||
stage: "awaiting-restart",
|
||||
requestedAt: "2026-07-18T11:59:00.000Z",
|
||||
});
|
||||
runtime.deleteContinuation = vi
|
||||
.fn<ReviewHarnessRuntime["deleteContinuation"]>()
|
||||
.mockRejectedValueOnce(new Error("continuation delete failed"))
|
||||
.mockImplementation(async () => {
|
||||
runtime.events.push("delete");
|
||||
runtime.continuation = null;
|
||||
});
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
await expect(controller.consumeContinuation()).rejects.toThrow("continuation delete failed");
|
||||
|
||||
expect(runtime.continuation).not.toBeNull();
|
||||
expect(controller.snapshot().resumedRequestId).toBeNull();
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
|
||||
|
||||
await controller.consumeContinuation();
|
||||
|
||||
expect(runtime.continuation).toBeNull();
|
||||
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
|
||||
});
|
||||
|
||||
it("passes only after the actual compatibility review clears its pause", async () => {
|
||||
const runtime = createRuntime();
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
+10
-1
@@ -1,4 +1,5 @@
|
||||
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
|
||||
import { LOG_LEVEL_URGENT } from "octagonal-wheels/common/logger";
|
||||
import { getLanguage, Notice, Plugin, type App, type PluginManifest } from "./deps";
|
||||
import { setGetLanguage } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
setGetLanguage(getLanguage);
|
||||
@@ -53,6 +54,7 @@ import { useObsidianReplicationRibbonFeature } from "./serviceFeatures/obsidianR
|
||||
import { useSyncGuardFeature } from "./serviceFeatures/syncGuard";
|
||||
import { SyncGuardOverlay } from "./serviceFeatures/syncGuard/SyncGuardOverlay";
|
||||
import { useStartupLifecycleFeature } from "./serviceFeatures/startupLifecycle";
|
||||
import { useJoinedDevicesFeature } from "./serviceFeatures/useJoinedDevices";
|
||||
import { useDatabaseDiagnosticsFeature } from "./serviceFeatures/useDatabaseDiagnostics";
|
||||
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
|
||||
export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
@@ -200,6 +202,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
useObsidianReplicationRibbonFeature(core);
|
||||
useSyncGuardFeature(core, (onBypass) => new SyncGuardOverlay(this.app.workspace.containerEl, onBypass));
|
||||
useDatabaseDiagnosticsFeature(core);
|
||||
useJoinedDevicesFeature(core, serviceHub.context.noticeGroups);
|
||||
useRemoteConfiguration(core);
|
||||
|
||||
useSetupProtocolFeature(core, setupManager);
|
||||
@@ -234,6 +237,12 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
void this._startUp();
|
||||
}
|
||||
override onunload(): void {
|
||||
return void this.core.services.control.onUnload();
|
||||
void this.core.services.control.onUnload().catch((error) => {
|
||||
this.core.services.API.addLog(
|
||||
"Failed to unload Self-hosted LiveSync: " + String(error),
|
||||
LOG_LEVEL_URGENT,
|
||||
"shutdown"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -230,7 +230,13 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
}
|
||||
this.isLastHidden = isHidden;
|
||||
|
||||
await this.services.fileProcessing.commitPendingFileEvents();
|
||||
const pendingFileEventsCommitted = await this.services.fileProcessing.commitPendingFileEvents();
|
||||
if (!pendingFileEventsCommitted) {
|
||||
this._log(
|
||||
"Pending file events remain because their commit did not complete before the window visibility changed.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop opt-in (LiveSync/Periodic only): keep the background channel running while the
|
||||
// window is hidden, instead of suspending on hide. On hide we skip the suspend for both
|
||||
@@ -287,7 +293,10 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
if (this.settings.suspendFileWatching) return;
|
||||
if (!this.settings.isConfigured) return;
|
||||
if (!this.services.appLifecycle.isReady()) return;
|
||||
await this.services.fileProcessing.commitPendingFileEvents();
|
||||
const pendingFileEventsCommitted = await this.services.fileProcessing.commitPendingFileEvents();
|
||||
if (!pendingFileEventsCommitted) {
|
||||
return;
|
||||
}
|
||||
if (file == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { ModuleObsidianEvents } from "./ModuleObsidianEvents";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||
|
||||
type SetupOptions = {
|
||||
settings?: Partial<typeof DEFAULT_SETTINGS>;
|
||||
@@ -23,6 +24,8 @@ function setup(opts: SetupOptions) {
|
||||
onResumed: vi.fn(async () => true),
|
||||
};
|
||||
const fileProcessing = { commitPendingFileEvents: vi.fn(async () => true) };
|
||||
const replication = { replicateUnattendedByEvent: vi.fn(async () => undefined) };
|
||||
const conflict = { queueCheckForIfOpen: vi.fn(async () => undefined) };
|
||||
const boundedRemoteActivityCount = reactiveSource(0);
|
||||
const boundedLocalApplicationActivityCount = reactiveSource(0);
|
||||
|
||||
@@ -39,6 +42,8 @@ function setup(opts: SetupOptions) {
|
||||
setting: { saveSettingData: vi.fn(async () => undefined) },
|
||||
appLifecycle,
|
||||
fileProcessing,
|
||||
replication,
|
||||
conflict,
|
||||
replicator: { boundedRemoteActivityCount, boundedLocalApplicationActivityCount },
|
||||
},
|
||||
settings: {
|
||||
@@ -61,6 +66,9 @@ function setup(opts: SetupOptions) {
|
||||
module,
|
||||
appLifecycle,
|
||||
fileProcessing,
|
||||
replication,
|
||||
conflict,
|
||||
api: core._services.API,
|
||||
boundedRemoteActivityCount,
|
||||
boundedLocalApplicationActivityCount,
|
||||
};
|
||||
@@ -303,6 +311,23 @@ describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", ()
|
||||
expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("logs a failed pending-file commit and still suspends on hide", async () => {
|
||||
const { module, appLifecycle, fileProcessing, api } = setup({
|
||||
settings: { keepReplicationActiveInBackground: false, liveSync: true },
|
||||
hidden: true,
|
||||
});
|
||||
fileProcessing.commitPendingFileEvents.mockResolvedValue(false);
|
||||
|
||||
await module.watchWindowVisibilityAsync();
|
||||
|
||||
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
|
||||
expect(api.addLog).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Pending file events remain"),
|
||||
LOG_LEVEL_NOTICE,
|
||||
expect.any(String)
|
||||
);
|
||||
});
|
||||
|
||||
it("does not apply on mobile even if the flag is set", async () => {
|
||||
const { module, appLifecycle } = setup({
|
||||
settings: { keepReplicationActiveInBackground: true, liveSync: true },
|
||||
@@ -313,3 +338,20 @@ describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", ()
|
||||
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("watchWorkspaceOpenAsync — pending file event commit", () => {
|
||||
it("skips replication and conflict checks when pending file events fail to commit", async () => {
|
||||
const { module, fileProcessing, replication, conflict } = setup({
|
||||
settings: { syncOnFileOpen: true },
|
||||
hidden: false,
|
||||
});
|
||||
fileProcessing.commitPendingFileEvents.mockResolvedValue(false);
|
||||
|
||||
await module.watchWorkspaceOpenAsync({ path: "note.md" } as Parameters<
|
||||
typeof module.watchWorkspaceOpenAsync
|
||||
>[0]);
|
||||
|
||||
expect(replication.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(conflict.queueCheckForIfOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
PREFIXMD_LOGFILE,
|
||||
type DatabaseConnectingStatus,
|
||||
type LOG_LEVEL,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { scheduleTask } from "octagonal-wheels/concurrency/task";
|
||||
@@ -31,16 +30,14 @@ import { LogPaneView, VIEW_TYPE_LOG } from "./Log/LogPaneView.ts";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector";
|
||||
import {
|
||||
REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS,
|
||||
formatRemoteActivityStatusLabel,
|
||||
getTrackedRequestCount,
|
||||
} from "./RemoteActivityStatus.ts";
|
||||
import { REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS, getTrackedRequestCount } from "./RemoteActivityStatus.ts";
|
||||
import {
|
||||
createChunkFetchCounterLabel,
|
||||
createMinimumVisibleActivityCount,
|
||||
createPaddedCounterLabel,
|
||||
} from "./StatusBarDisplay.ts";
|
||||
import { createFastForwardCheckedChangesDisplay, projectReplicationStatusLabel } from "./ReplicationStatusLabel.ts";
|
||||
import { createStatusBarRenderer, type StatusBarPart, type StatusBarRenderer } from "./StatusBarRenderer.ts";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
|
||||
import { isValidPath } from "@/common/utils.ts";
|
||||
@@ -124,7 +121,14 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
logHistory?: HTMLDivElement;
|
||||
messageArea?: HTMLDivElement;
|
||||
|
||||
statusBarLabels!: ReactiveValue<{ message: string; status: string }>;
|
||||
statusBarRenderer?: StatusBarRenderer;
|
||||
statusLineRenderer?: StatusBarRenderer;
|
||||
statusBarLabels!: ReactiveValue<{
|
||||
message: string;
|
||||
status: string;
|
||||
parts: readonly StatusBarPart[];
|
||||
statusBarParts: readonly StatusBarPart[];
|
||||
}>;
|
||||
statusLog = reactiveSource("");
|
||||
activeFileStatus = reactiveSource("");
|
||||
notifies: { [key: string]: { count: number } } = {};
|
||||
@@ -164,79 +168,54 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
);
|
||||
|
||||
const requestingStatLabel = computed(() => {
|
||||
return formatRemoteActivityStatusLabel({
|
||||
return {
|
||||
remoteOperationCount: Math.max(0, this.services.replicator.boundedRemoteActivityCount.value),
|
||||
trackedRequestCount: displayedTrackedRequestCount.value,
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
const replicationStatLabel = computed(() => {
|
||||
const e = this.services.replicator.replicationStatics.value;
|
||||
const sent = e.sent;
|
||||
const arrived = e.arrived;
|
||||
const maxPullSeq = e.maxPullSeq;
|
||||
const maxPushSeq = e.maxPushSeq;
|
||||
const lastSyncPullSeq = e.lastSyncPullSeq;
|
||||
const lastSyncPushSeq = e.lastSyncPushSeq;
|
||||
let pushLast = "";
|
||||
let pullLast = "";
|
||||
let w = "";
|
||||
const labels: Partial<Record<DatabaseConnectingStatus, string>> = {
|
||||
CONNECTED: "⚡",
|
||||
JOURNAL_SEND: "📦↑",
|
||||
JOURNAL_RECEIVE: "📦↓",
|
||||
};
|
||||
switch (e.syncStatus) {
|
||||
case "CLOSED":
|
||||
case "COMPLETED":
|
||||
case "NOT_CONNECTED":
|
||||
w = "⏹";
|
||||
break;
|
||||
case "STARTED":
|
||||
w = "🌀";
|
||||
break;
|
||||
case "PAUSED":
|
||||
w = "💤";
|
||||
break;
|
||||
case "CONNECTED":
|
||||
case "JOURNAL_SEND":
|
||||
case "JOURNAL_RECEIVE":
|
||||
w = labels[e.syncStatus] || "⚡";
|
||||
pushLast =
|
||||
lastSyncPushSeq == 0
|
||||
? ""
|
||||
: lastSyncPushSeq >= maxPushSeq
|
||||
? " (LIVE)"
|
||||
: ` (${maxPushSeq - lastSyncPushSeq})`;
|
||||
pullLast =
|
||||
lastSyncPullSeq == 0
|
||||
? ""
|
||||
: lastSyncPullSeq >= maxPullSeq
|
||||
? " (LIVE)"
|
||||
: ` (${maxPullSeq - lastSyncPullSeq})`;
|
||||
break;
|
||||
case "ERRORED":
|
||||
w = "⚠";
|
||||
break;
|
||||
default:
|
||||
w = "?";
|
||||
}
|
||||
return { w, sent, pushLast, arrived, pullLast };
|
||||
});
|
||||
const fastForwardCount = registerDisplay(
|
||||
createFastForwardCheckedChangesDisplay(this.services.replicator.replicationStatics)
|
||||
);
|
||||
const replicationStatLabel = computed(() =>
|
||||
projectReplicationStatusLabel(
|
||||
this.services.replicator.replicationStatics.value,
|
||||
requestingStatLabel(),
|
||||
fastForwardCount.value
|
||||
)
|
||||
);
|
||||
const labelProc = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.processing, `⏳`));
|
||||
const labelPend = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.totalQueued, `🛫`));
|
||||
const labelInBatchDelay = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.batched, `📬`));
|
||||
const waitingLabel = computed(() => {
|
||||
return `${labelProc.value}${labelPend.value}${labelInBatchDelay.value}`;
|
||||
});
|
||||
const counterPart = (key: string, mark: string, value: number | undefined): StatusBarPart[] =>
|
||||
value === undefined ? [] : [{ key, text: " " + mark, value }];
|
||||
const statusLineLabel = computed(() => {
|
||||
const { w, sent, pushLast, arrived, pullLast } = replicationStatLabel();
|
||||
const replication = replicationStatLabel();
|
||||
const queued = queueCountLabel();
|
||||
const waiting = waitingLabel();
|
||||
const networkActivity = requestingStatLabel();
|
||||
const p2p = this.p2pLogCollector.p2pReplicationLine.value;
|
||||
const firstLineParts = [
|
||||
...replication.parts,
|
||||
...counterPart("file-processing", "⏳", labelProc.count.value),
|
||||
...counterPart("file-pending", "🛫", labelPend.count.value),
|
||||
...counterPart("file-batched", "📬", labelInBatchDelay.count.value),
|
||||
...counterPart("queue-replication", "📥", labelReplication.count.value),
|
||||
...counterPart("queue-database", "📄", labelDBCount.count.value),
|
||||
...counterPart("queue-storage", "💾", labelStorageCount.count.value),
|
||||
...counterPart("queue-chunk-initial", "🛄", labelChunkCount.initialCount.value),
|
||||
...counterPart("queue-chunk-retrying", "🔁", labelChunkCount.retryingCount.value),
|
||||
...counterPart("queue-plugin-scan", "🔌", labelPluginScanCount.count.value),
|
||||
...counterPart("queue-hidden-files", "⚙️", labelHiddenFilesCount.count.value),
|
||||
...counterPart("queue-conflicts", "🔩", labelConflictProcessCount.count.value),
|
||||
];
|
||||
return {
|
||||
message: `${networkActivity}Sync: ${w} ↑ ${sent}${pushLast} ↓ ${arrived}${pullLast}${waiting}${queued}${p2p == "" ? "" : "\n" + p2p}`,
|
||||
message: replication.message + waiting + queued + (p2p === "" ? "" : "\n" + p2p),
|
||||
parts: p2p === "" ? firstLineParts : [...firstLineParts, { key: "p2p", text: "\n" + p2p }],
|
||||
statusBarParts: firstLineParts,
|
||||
hasP2P: p2p !== "",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -244,13 +223,16 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
const scheduleMessage = this.services.appLifecycle.isReloadingScheduled()
|
||||
? `WARNING! RESTARTING OBSIDIAN IS SCHEDULED!\n`
|
||||
: "";
|
||||
const { message } = statusLineLabel();
|
||||
const { message, parts, statusBarParts, hasP2P } = statusLineLabel();
|
||||
const fileStatus = this.activeFileStatus.value;
|
||||
const status = scheduleMessage + this.statusLog.value;
|
||||
const fileStatusIcon = `${fileStatus && this.settings.hideFileWarningNotice ? " ⛔ SKIP" : ""}`;
|
||||
const skipPart = fileStatusIcon === "" ? [] : [{ key: "file-skip", text: fileStatusIcon }];
|
||||
return {
|
||||
message: `${message}${fileStatusIcon}`,
|
||||
status,
|
||||
parts: [...parts, ...skipPart],
|
||||
statusBarParts: hasP2P ? statusBarParts : [...statusBarParts, ...skipPart],
|
||||
};
|
||||
});
|
||||
this.statusBarLabels = statusBarLabels;
|
||||
@@ -391,7 +373,8 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
newLog = newLog.substring(moduleTagEnd + MARK_LOG_SEPARATOR.length + 1);
|
||||
}
|
||||
|
||||
this.statusBar?.setText(newMsg.split("\n")[0]);
|
||||
const { parts, statusBarParts } = this.statusBarLabels.value;
|
||||
this.statusBarRenderer?.render(statusBarParts);
|
||||
if (this.statusDiv) {
|
||||
this.statusDiv.setCssStyles({ display: this.settings?.showStatusOnEditor ? "" : "none" });
|
||||
}
|
||||
@@ -409,7 +392,7 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
const recentLogs = recent.reverse().join("\n");
|
||||
if (isDirty("recentLogs", recentLogs)) this.logHistory!.innerText = recentLogs;
|
||||
}
|
||||
if (isDirty("newMsg", newMsg)) this.statusLine!.innerText = newMsg;
|
||||
if (isDirty("newMsg", newMsg)) this.statusLineRenderer?.render(parts);
|
||||
if (isDirty("newLog", newLog)) this.logMessage!.innerText = newLog;
|
||||
} else {
|
||||
// const root = activeDocument.documentElement;
|
||||
@@ -505,6 +488,16 @@ ${stringifyYaml(info)}
|
||||
this.statusBar = this.services.API.addStatusBarItem();
|
||||
this.statusBar?.addClass("syncstatusbar");
|
||||
}
|
||||
if (this.statusLine) {
|
||||
const renderer = createStatusBarRenderer(this.statusLine);
|
||||
this.statusLineRenderer = renderer;
|
||||
this.plugin.register(() => renderer.dispose());
|
||||
}
|
||||
if (this.statusBar) {
|
||||
const renderer = createStatusBarRenderer(this.statusBar);
|
||||
this.statusBarRenderer = renderer;
|
||||
this.plugin.register(() => renderer.dispose());
|
||||
}
|
||||
this.adjustStatusDivPosition();
|
||||
this._log("Log module loaded", LOG_LEVEL_INFO);
|
||||
this._log("Verbose log", LOG_LEVEL_VERBOSE);
|
||||
|
||||
@@ -8,13 +8,13 @@ import { openObsidianSettings } from "@/common/obsidianSettings.ts";
|
||||
export class ModuleObsidianSettingDialogue extends AbstractObsidianModule {
|
||||
settingTab!: ObsidianLiveSyncSettingTab;
|
||||
|
||||
_everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||
async _everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||
this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin);
|
||||
this.settingTab.reloadAllSettings(true);
|
||||
await this.settingTab.loadSettings();
|
||||
this.plugin.addSettingTab(this.settingTab);
|
||||
eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => this.openSetting());
|
||||
|
||||
return Promise.resolve(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
openSetting() {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const settingTabState = vi.hoisted(() => ({
|
||||
callOrder: [] as string[],
|
||||
reloadAllSettings: vi.fn<(skipUpdate?: boolean) => void>(),
|
||||
loadSettings: vi.fn<() => Promise<void>>(),
|
||||
}));
|
||||
|
||||
const eventHubState = vi.hoisted(() => ({
|
||||
@@ -11,9 +11,9 @@ const eventHubState = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("./SettingDialogue/ObsidianLiveSyncSettingTab.ts", () => ({
|
||||
ObsidianLiveSyncSettingTab: class ObsidianLiveSyncSettingTab {
|
||||
reloadAllSettings(skipUpdate?: boolean) {
|
||||
settingTabState.callOrder.push(`reload:${String(skipUpdate)}`);
|
||||
settingTabState.reloadAllSettings(skipUpdate);
|
||||
async loadSettings() {
|
||||
await settingTabState.loadSettings();
|
||||
settingTabState.callOrder.push("settings-loaded");
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -65,7 +65,7 @@ function createModuleHarness() {
|
||||
describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
settingTabState.callOrder.length = 0;
|
||||
settingTabState.reloadAllSettings.mockClear();
|
||||
settingTabState.loadSettings.mockReset().mockResolvedValue(undefined);
|
||||
eventHubState.onEvent.mockClear();
|
||||
});
|
||||
|
||||
@@ -79,13 +79,29 @@ describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
|
||||
});
|
||||
|
||||
it("seeds the setting editor without requesting a render before registration", async () => {
|
||||
const { initialisationHandler, settingsLoadedHandler } = createModuleHarness();
|
||||
const { initialisationHandler, settingsLoadedHandler, plugin } = createModuleHarness();
|
||||
let finishRead!: () => void;
|
||||
settingTabState.loadSettings.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
finishRead = resolve;
|
||||
})
|
||||
);
|
||||
const handler = settingsLoadedHandler() ?? initialisationHandler();
|
||||
|
||||
expect(handler).toBeTypeOf("function");
|
||||
await handler!();
|
||||
const loading = handler!();
|
||||
expect(plugin.addSettingTab).not.toHaveBeenCalled();
|
||||
finishRead();
|
||||
await loading;
|
||||
|
||||
expect(settingTabState.reloadAllSettings).toHaveBeenCalledWith(true);
|
||||
expect(settingTabState.callOrder).toEqual(["reload:true", "add-setting-tab"]);
|
||||
expect(settingTabState.loadSettings).toHaveBeenCalledOnce();
|
||||
expect(settingTabState.callOrder).toEqual(["settings-loaded", "add-setting-tab"]);
|
||||
});
|
||||
it("does not register an editor when reading device values fails", async () => {
|
||||
const { settingsLoadedHandler, plugin } = createModuleHarness();
|
||||
const failure = new Error("device storage unavailable");
|
||||
settingTabState.loadSettings.mockRejectedValueOnce(failure);
|
||||
await expect(settingsLoadedHandler()!()).rejects.toBe(failure);
|
||||
expect(plugin.addSettingTab).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { reactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive";
|
||||
import type { ReplicationStatics } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
import type { DisposableReactiveValue } from "./StatusBarDisplay.ts";
|
||||
import type { StatusBarPart } from "./StatusBarRenderer.ts";
|
||||
import {
|
||||
formatRemoteActivityStatusLabel,
|
||||
REMOTE_OPERATION_ACTIVITY_ICON,
|
||||
REMOTE_REQUEST_ACTIVITY_ICON,
|
||||
type RemoteActivityStatus,
|
||||
} from "./RemoteActivityStatus.ts";
|
||||
|
||||
export const FAST_FORWARD_COUNT_LINGER_MS = 3_000;
|
||||
|
||||
function readPositiveCheckedChanges(source: ReactiveValue<ReplicationStatics>): number | undefined {
|
||||
const count = source.value.couchDB?.pull?.checkedChanges;
|
||||
return typeof count === "number" && Number.isFinite(count) && count > 0 ? count : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Briefly displays changing CouchDB pull-feed comparison counts.
|
||||
*
|
||||
* Unrelated replication-stat changes do not extend the linger or redisplay an
|
||||
* expired count. A positive initial snapshot is displayed to late subscribers.
|
||||
*/
|
||||
export function createFastForwardCheckedChangesDisplay(
|
||||
source: ReactiveValue<ReplicationStatics>,
|
||||
lingerMs = FAST_FORWARD_COUNT_LINGER_MS
|
||||
): DisposableReactiveValue<number | undefined> {
|
||||
const displayed = reactiveSource<number | undefined>(undefined);
|
||||
const linger = Math.max(0, lingerMs);
|
||||
let lastObserved = readPositiveCheckedChanges(source);
|
||||
let clearTimer: number | undefined;
|
||||
let disposed = false;
|
||||
|
||||
const cancelClear = () => {
|
||||
if (clearTimer === undefined) return;
|
||||
compatGlobal.clearTimeout(clearTimer);
|
||||
clearTimer = undefined;
|
||||
};
|
||||
const scheduleClear = () => {
|
||||
clearTimer = compatGlobal.setTimeout(() => {
|
||||
clearTimer = undefined;
|
||||
if (disposed) return;
|
||||
if (readPositiveCheckedChanges(source) !== lastObserved) {
|
||||
update();
|
||||
return;
|
||||
}
|
||||
displayed.value = undefined;
|
||||
}, linger);
|
||||
};
|
||||
const update = () => {
|
||||
if (disposed) return;
|
||||
const nextCount = readPositiveCheckedChanges(source);
|
||||
if (nextCount === lastObserved) return;
|
||||
lastObserved = nextCount;
|
||||
cancelClear();
|
||||
if (nextCount === undefined) {
|
||||
displayed.value = undefined;
|
||||
return;
|
||||
}
|
||||
displayed.value = nextCount;
|
||||
scheduleClear();
|
||||
};
|
||||
|
||||
if (lastObserved !== undefined) {
|
||||
displayed.value = lastObserved;
|
||||
scheduleClear();
|
||||
}
|
||||
source.onChanged(update);
|
||||
|
||||
return {
|
||||
get value() {
|
||||
return displayed.value;
|
||||
},
|
||||
onChanged(handler) {
|
||||
displayed.onChanged(handler);
|
||||
},
|
||||
offChanged(handler) {
|
||||
displayed.offChanged(handler);
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
cancelClear();
|
||||
source.offChanged(update);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ReplicationStatusProjection = {
|
||||
icon: string;
|
||||
message: string;
|
||||
parts: StatusBarPart[];
|
||||
};
|
||||
|
||||
function remainingSequenceLabel(
|
||||
key: string,
|
||||
lastSyncSeq: number,
|
||||
maxSeq: number
|
||||
): { text: string; part?: StatusBarPart } {
|
||||
if (lastSyncSeq === 0) return { text: "" };
|
||||
if (lastSyncSeq >= maxSeq) return { text: " (LIVE)", part: { key, text: " (LIVE)" } };
|
||||
const remaining = maxSeq - lastSyncSeq;
|
||||
return {
|
||||
text: " (" + remaining + ")",
|
||||
part: { key, text: " (", value: remaining, suffix: ")" },
|
||||
};
|
||||
}
|
||||
|
||||
/** Projects the existing replication labels into reusable numeric status items. */
|
||||
export function projectReplicationStatusLabel(
|
||||
stats: ReplicationStatics,
|
||||
networkStatus: RemoteActivityStatus,
|
||||
fastForwardCount?: number
|
||||
): ReplicationStatusProjection {
|
||||
const { sent, arrived, maxPullSeq, maxPushSeq, lastSyncPullSeq, lastSyncPushSeq } = stats;
|
||||
const networkActivity = formatRemoteActivityStatusLabel(networkStatus);
|
||||
let pushRemaining: { text: string; part?: StatusBarPart } = { text: "" };
|
||||
let pullRemaining: { text: string; part?: StatusBarPart } = { text: "" };
|
||||
let icon: string;
|
||||
switch (stats.syncStatus) {
|
||||
case "CLOSED":
|
||||
case "COMPLETED":
|
||||
case "NOT_CONNECTED":
|
||||
icon = "⏹";
|
||||
break;
|
||||
case "STARTED":
|
||||
icon = "🌀";
|
||||
break;
|
||||
case "PAUSED":
|
||||
icon = "💤";
|
||||
break;
|
||||
case "CONNECTED":
|
||||
case "JOURNAL_SEND":
|
||||
case "JOURNAL_RECEIVE":
|
||||
icon = stats.syncStatus === "JOURNAL_SEND" ? "📦↑" : stats.syncStatus === "JOURNAL_RECEIVE" ? "📦↓" : "⚡";
|
||||
pushRemaining = remainingSequenceLabel("push-remaining", lastSyncPushSeq, maxPushSeq);
|
||||
pullRemaining = remainingSequenceLabel("pull-remaining", lastSyncPullSeq, maxPullSeq);
|
||||
break;
|
||||
case "ERRORED":
|
||||
icon = "⚠";
|
||||
break;
|
||||
default:
|
||||
icon = "?";
|
||||
}
|
||||
|
||||
const couchDB = stats.couchDB;
|
||||
const isFastForwardChecking =
|
||||
stats.syncStatus !== "ERRORED" &&
|
||||
couchDB !== undefined &&
|
||||
couchDB.phase !== "idle" &&
|
||||
(couchDB.pull?.checking === true || couchDB.push?.checking === true);
|
||||
if (isFastForwardChecking) icon = "⏩";
|
||||
|
||||
const visibleFastForwardCount =
|
||||
couchDB !== undefined &&
|
||||
typeof fastForwardCount === "number" &&
|
||||
Number.isFinite(fastForwardCount) &&
|
||||
fastForwardCount > 0
|
||||
? fastForwardCount
|
||||
: undefined;
|
||||
const fastForwardLabel = visibleFastForwardCount === undefined ? "" : " (⏩ " + visibleFastForwardCount + ")";
|
||||
|
||||
const parts: StatusBarPart[] = [];
|
||||
if (networkStatus.trackedRequestCount > 0) {
|
||||
parts.push({
|
||||
key: "network",
|
||||
text:
|
||||
(networkStatus.remoteOperationCount > 0 ? `${REMOTE_OPERATION_ACTIVITY_ICON} ` : "") +
|
||||
REMOTE_REQUEST_ACTIVITY_ICON,
|
||||
value: networkStatus.trackedRequestCount,
|
||||
suffix: " ",
|
||||
});
|
||||
} else if (networkActivity !== "") {
|
||||
parts.push({ key: "network", text: networkActivity });
|
||||
}
|
||||
parts.push({ key: "sync", text: "Sync: " + icon });
|
||||
if (visibleFastForwardCount !== undefined) {
|
||||
parts.push({
|
||||
key: "ff",
|
||||
text: " (⏩ ",
|
||||
value: visibleFastForwardCount,
|
||||
suffix: ")",
|
||||
title: "Compared CouchDB feed entries",
|
||||
});
|
||||
}
|
||||
parts.push({ key: "sent", text: " ↑ ", value: sent });
|
||||
if (pushRemaining.part !== undefined) parts.push(pushRemaining.part);
|
||||
parts.push({ key: "arrived", text: " ↓ ", value: arrived });
|
||||
if (pullRemaining.part !== undefined) parts.push(pullRemaining.part);
|
||||
|
||||
return {
|
||||
icon,
|
||||
message:
|
||||
networkActivity +
|
||||
"Sync: " +
|
||||
icon +
|
||||
fastForwardLabel +
|
||||
" ↑ " +
|
||||
sent +
|
||||
pushRemaining.text +
|
||||
" ↓ " +
|
||||
arrived +
|
||||
pullRemaining.text,
|
||||
parts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ReplicationStatics } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
import {
|
||||
FAST_FORWARD_COUNT_LINGER_MS,
|
||||
createFastForwardCheckedChangesDisplay,
|
||||
projectReplicationStatusLabel,
|
||||
} from "./ReplicationStatusLabel.ts";
|
||||
|
||||
function couchDBProgress(
|
||||
phase: "idle" | "one-shot" | "catch-up" | "continuous",
|
||||
checkedChanges = 0,
|
||||
checking = false
|
||||
): NonNullable<ReplicationStatics["couchDB"]> {
|
||||
return {
|
||||
phase,
|
||||
pull: { checkedChanges, completedBatches: 0, checking },
|
||||
push: { checkedChanges: 0, completedBatches: 0, checking: false },
|
||||
};
|
||||
}
|
||||
|
||||
const noNetworkActivity = { remoteOperationCount: 0, trackedRequestCount: 0 };
|
||||
|
||||
function stats(overrides: Partial<ReplicationStatics> = {}): ReplicationStatics {
|
||||
return {
|
||||
sent: 0,
|
||||
arrived: 0,
|
||||
maxPullSeq: 0,
|
||||
maxPushSeq: 0,
|
||||
lastSyncPullSeq: 0,
|
||||
lastSyncPushSeq: 0,
|
||||
syncStatus: "CONNECTED",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("projectReplicationStatusLabel", () => {
|
||||
it("exposes the remote-request count as a numeric status item", () => {
|
||||
const projection = projectReplicationStatusLabel(stats(), { remoteOperationCount: 1, trackedRequestCount: 12 });
|
||||
expect(projection.parts.find((part) => part.key === "network")).toMatchObject({
|
||||
text: "📲 🌐",
|
||||
value: 12,
|
||||
suffix: " ",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the connected label, directions, and remaining-sequence text", () => {
|
||||
const projection = projectReplicationStatusLabel(
|
||||
stats({
|
||||
sent: 4,
|
||||
arrived: 2,
|
||||
maxPullSeq: 20,
|
||||
maxPushSeq: 30,
|
||||
lastSyncPullSeq: 17,
|
||||
lastSyncPushSeq: 22,
|
||||
}),
|
||||
{ remoteOperationCount: 0, trackedRequestCount: 1 }
|
||||
);
|
||||
|
||||
expect(projection.message).toBe("🌐1 Sync: ⚡ ↑ 4 (8) ↓ 2 (3)");
|
||||
expect(projection.parts.map((part) => part.key)).toEqual([
|
||||
"network",
|
||||
"sync",
|
||||
"sent",
|
||||
"push-remaining",
|
||||
"arrived",
|
||||
"pull-remaining",
|
||||
]);
|
||||
expect(projection.parts[2]).toMatchObject({ key: "sent", text: " ↑ ", value: 4 });
|
||||
expect(projection.parts[3]).toMatchObject({ key: "push-remaining", text: " (", value: 8, suffix: ")" });
|
||||
expect(projection.parts[4]).toMatchObject({ key: "arrived", text: " ↓ ", value: 2 });
|
||||
expect(projection.parts[5]).toMatchObject({ key: "pull-remaining", text: " (", value: 3, suffix: ")" });
|
||||
});
|
||||
|
||||
it("keeps the existing status icons when CouchDB is idle or not present", () => {
|
||||
const statuses = [
|
||||
["CLOSED", "⏹"],
|
||||
["COMPLETED", "⏹"],
|
||||
["NOT_CONNECTED", "⏹"],
|
||||
["STARTED", "🌀"],
|
||||
["PAUSED", "💤"],
|
||||
["CONNECTED", "⚡"],
|
||||
["JOURNAL_SEND", "📦↑"],
|
||||
["JOURNAL_RECEIVE", "📦↓"],
|
||||
["ERRORED", "⚠"],
|
||||
] as const;
|
||||
|
||||
for (const [syncStatus, icon] of statuses) {
|
||||
const idleWithCount = projectReplicationStatusLabel(
|
||||
stats({ syncStatus, couchDB: couchDBProgress("idle", 19, true) }),
|
||||
noNetworkActivity,
|
||||
19
|
||||
);
|
||||
expect(idleWithCount.icon).toBe(icon);
|
||||
expect(idleWithCount.parts.some((part) => part.key === "ff")).toBe(true);
|
||||
|
||||
const withoutCouchDB = projectReplicationStatusLabel(stats({ syncStatus }), noNetworkActivity, 19);
|
||||
expect(withoutCouchDB.icon).toBe(icon);
|
||||
expect(withoutCouchDB.parts.some((part) => part.key === "ff")).toBe(false);
|
||||
}
|
||||
|
||||
const noFastForward = projectReplicationStatusLabel(stats(), noNetworkActivity);
|
||||
expect(noFastForward.parts.some((part) => part.key === "ff")).toBe(false);
|
||||
expect(noFastForward.message).toBe("Sync: ⚡ ↑ 0 ↓ 0");
|
||||
});
|
||||
|
||||
it("shows fast-forward checking with zero arrivals, transferred data, and the final idle count", () => {
|
||||
const checking = projectReplicationStatusLabel(
|
||||
stats({
|
||||
syncStatus: "PAUSED",
|
||||
arrived: 0,
|
||||
couchDB: couchDBProgress("catch-up", 0, true),
|
||||
}),
|
||||
noNetworkActivity
|
||||
);
|
||||
expect(checking.icon).toBe("⏩");
|
||||
expect(checking.parts[2]).toMatchObject({ key: "arrived", value: 0 });
|
||||
|
||||
const transferred = projectReplicationStatusLabel(
|
||||
stats({
|
||||
arrived: 7,
|
||||
couchDB: couchDBProgress("continuous", 1200, false),
|
||||
}),
|
||||
noNetworkActivity,
|
||||
1200
|
||||
);
|
||||
expect(transferred.icon).toBe("⚡");
|
||||
expect(transferred.message).toBe("Sync: ⚡ (⏩ 1200) ↑ 0 ↓ 7");
|
||||
expect(transferred.parts.map((part) => part.key)).toEqual(["sync", "ff", "sent", "arrived"]);
|
||||
expect(transferred.parts[1]).toMatchObject({
|
||||
key: "ff",
|
||||
text: " (⏩ ",
|
||||
value: 1200,
|
||||
suffix: ")",
|
||||
});
|
||||
|
||||
const idle = projectReplicationStatusLabel(
|
||||
stats({
|
||||
syncStatus: "CONNECTED",
|
||||
arrived: 7,
|
||||
couchDB: couchDBProgress("idle", 1200, false),
|
||||
}),
|
||||
noNetworkActivity,
|
||||
1200
|
||||
);
|
||||
expect(idle.icon).toBe("⚡");
|
||||
expect(idle.message).toBe("Sync: ⚡ (⏩ 1200) ↑ 0 ↓ 7");
|
||||
});
|
||||
|
||||
it("keeps the error icon ahead of an active CouchDB checking indicator", () => {
|
||||
const projection = projectReplicationStatusLabel(
|
||||
stats({
|
||||
syncStatus: "ERRORED",
|
||||
couchDB: {
|
||||
...couchDBProgress("one-shot", 2, true),
|
||||
push: { checkedChanges: 0, completedBatches: 0, checking: true },
|
||||
},
|
||||
}),
|
||||
noNetworkActivity
|
||||
);
|
||||
|
||||
expect(projection.icon).toBe("⚠");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFastForwardCheckedChangesDisplay", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("uses the initial snapshot, expires unchanged counts, and only reappears for new progress", () => {
|
||||
const source = reactiveSource(
|
||||
stats({
|
||||
couchDB: couchDBProgress("catch-up", 1200, true),
|
||||
})
|
||||
);
|
||||
const display = createFastForwardCheckedChangesDisplay(source);
|
||||
|
||||
expect(display.value).toBe(1200);
|
||||
vi.advanceTimersByTime(1000);
|
||||
source.value = stats({
|
||||
sent: 3,
|
||||
couchDB: couchDBProgress("idle", 1200, false),
|
||||
});
|
||||
expect(display.value).toBe(1200);
|
||||
vi.advanceTimersByTime(FAST_FORWARD_COUNT_LINGER_MS - 1001);
|
||||
expect(display.value).toBe(1200);
|
||||
|
||||
source.value = stats({
|
||||
sent: 4,
|
||||
couchDB: couchDBProgress("idle", 1200, false),
|
||||
});
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(display.value).toBeUndefined();
|
||||
|
||||
source.value = stats({
|
||||
syncStatus: "PAUSED",
|
||||
couchDB: couchDBProgress("continuous", 1200, true),
|
||||
});
|
||||
expect(display.value).toBeUndefined();
|
||||
|
||||
source.value = stats({
|
||||
couchDB: couchDBProgress("continuous", 1201, true),
|
||||
});
|
||||
expect(display.value).toBe(1201);
|
||||
|
||||
source.value = stats({
|
||||
couchDB: couchDBProgress("one-shot", 0, true),
|
||||
});
|
||||
expect(display.value).toBeUndefined();
|
||||
|
||||
source.value = stats();
|
||||
expect(display.value).toBeUndefined();
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it("clears its timer and subscription when disposed", () => {
|
||||
const source = reactiveSource(stats({ couchDB: couchDBProgress("catch-up", 5, true) }));
|
||||
const display = createFastForwardCheckedChangesDisplay(source);
|
||||
|
||||
display.dispose();
|
||||
vi.advanceTimersByTime(FAST_FORWARD_COUNT_LINGER_MS);
|
||||
source.value = stats({ couchDB: couchDBProgress("continuous", 6, true) });
|
||||
|
||||
expect(display.value).toBe(5);
|
||||
});
|
||||
|
||||
it("does not display progress for providers without CouchDB replication state", () => {
|
||||
const source = reactiveSource(stats());
|
||||
const display = createFastForwardCheckedChangesDisplay(source);
|
||||
|
||||
expect(display.value).toBeUndefined();
|
||||
source.value = stats({ couchDB: { phase: "continuous" } });
|
||||
expect(display.value).toBeUndefined();
|
||||
display.dispose();
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ export function createAdvancedSettingSpecGroups({
|
||||
control: { type: "dropdown", options: () => ChunkAlgorithmNames },
|
||||
},
|
||||
{ key: "customChunkSize", control: { type: "number", min: 0, allowZero: true } },
|
||||
{ key: "strictContentCheckOnStartup", control: { type: "toggle" } },
|
||||
{
|
||||
key: "pathNormalizationVersion",
|
||||
holdValue: true,
|
||||
|
||||
+8
-6
@@ -1,3 +1,4 @@
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { SettingDefinitionGroup, SettingDefinitionItem, SettingDefinitionPage } from "obsidian";
|
||||
@@ -158,6 +159,7 @@ function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSe
|
||||
},
|
||||
services: {
|
||||
setting: {
|
||||
deviceValues: new MemoryValueStore(),
|
||||
getDeviceAndVaultName: vi.fn(() => ""),
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
},
|
||||
@@ -459,7 +461,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
expect(runtime.pageCleanup).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rebuilds the catalogue when an externally loaded setting changes page visibility", () => {
|
||||
it("rebuilds the catalogue when an externally loaded setting changes page visibility", async () => {
|
||||
const tab = createSettingsTab();
|
||||
const changeLog = findPage(tab, "Change Log");
|
||||
if (!changeLog.page) {
|
||||
@@ -468,7 +470,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
changeLog.page().display();
|
||||
tab.core.settings.usePowerUserMode = !tab.editingSettings.usePowerUserMode;
|
||||
|
||||
tab.requestReload();
|
||||
await tab.requestReload();
|
||||
|
||||
expect(tab.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -482,7 +484,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
expect(tab.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rebuilds translated catalogue names when the display language changes externally", () => {
|
||||
it("rebuilds translated catalogue names when the display language changes externally", async () => {
|
||||
const tab = createSettingsTab();
|
||||
const changeLog = findPage(tab, "Change Log");
|
||||
if (!changeLog.page) {
|
||||
@@ -491,12 +493,12 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
changeLog.page().display();
|
||||
tab.core.settings.displayLanguage = "ja";
|
||||
|
||||
tab.requestReload();
|
||||
await tab.requestReload();
|
||||
|
||||
expect(tab.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rebuilds the catalogue after accepting an external page-visibility setting over a dirty value", () => {
|
||||
it("rebuilds the catalogue after accepting an external page-visibility setting over a dirty value", async () => {
|
||||
const tab = createSettingsTab();
|
||||
const changeLog = findPage(tab, "Change Log");
|
||||
if (!changeLog.page) {
|
||||
@@ -507,7 +509,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
tab.editingSettings.usePowerUserMode = true;
|
||||
tab.core.settings.usePowerUserMode = true;
|
||||
|
||||
tab.requestReload();
|
||||
await tab.requestReload();
|
||||
const configureAnchor = vi.mocked(tab.core.confirm.askInPopup).mock.calls[0]?.[2];
|
||||
expect(configureAnchor).toBeTypeOf("function");
|
||||
let acceptExternalSetting: (() => void) | undefined;
|
||||
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
type PageFunctions,
|
||||
type UpdateFunction,
|
||||
} from "./SettingPane.ts";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { JournalSyncCore } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncCore";
|
||||
import { MinioStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/replication/journal/objectstore/MinioStorageAdapter";
|
||||
import { closeObsidianSettings } from "@/common/obsidianSettings.ts";
|
||||
@@ -100,6 +99,9 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
}
|
||||
selectedScreen = "";
|
||||
|
||||
private localConfigurationPassphrase = "";
|
||||
private localSettingsReadVersion = 0;
|
||||
|
||||
_editingSettings?: AllSettings;
|
||||
// Buffered Settings for editing
|
||||
get editingSettings(): AllSettings {
|
||||
@@ -162,8 +164,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
async saveLocalSetting(key: keyof typeof OnDialogSettingsDefault) {
|
||||
if (key == "deviceAndVaultName") {
|
||||
this.services.setting.setDeviceAndVaultName(this.editingSettings?.[key] ?? "");
|
||||
this.services.setting.saveDeviceAndVaultName();
|
||||
return await Promise.resolve();
|
||||
await this.services.setting.saveDeviceAndVaultName();
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -225,6 +226,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
appliedConfigurationEncryption.configPassphrase
|
||||
);
|
||||
|
||||
if (appliedConfigurationEncryption.configPassphraseStore === "LOCALSTORAGE") {
|
||||
this.localSettingsReadVersion++;
|
||||
this.localConfigurationPassphrase = appliedConfigurationEncryption.configPassphrase;
|
||||
}
|
||||
this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphraseStore");
|
||||
this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphrase");
|
||||
}
|
||||
@@ -285,9 +290,21 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
}
|
||||
}
|
||||
|
||||
private async readLocalSettings(): Promise<boolean> {
|
||||
const version = ++this.localSettingsReadVersion;
|
||||
const passphrase = await this.services.setting.deviceValues.getValue("ls-setting-passphrase", "");
|
||||
if (version !== this.localSettingsReadVersion) return false;
|
||||
this.localConfigurationPassphrase = passphrase;
|
||||
return true;
|
||||
}
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
if (await this.readLocalSettings()) this.reloadAllSettings(true);
|
||||
}
|
||||
|
||||
reloadAllLocalSettings() {
|
||||
const ret = { ...OnDialogSettingsDefault };
|
||||
ret.configPassphrase = compatGlobal.localStorage.getItem("ls-setting-passphrase") || "";
|
||||
ret.configPassphrase = this.localConfigurationPassphrase;
|
||||
ret.preset = "";
|
||||
ret.deviceAndVaultName = this.services.setting.getDeviceAndVaultName();
|
||||
return ret;
|
||||
@@ -402,7 +419,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
this.plugin = plugin;
|
||||
Setting.env = this;
|
||||
eventHub.onEvent(EVENT_REQUEST_RELOAD_SETTING_TAB, () => {
|
||||
this.requestReload();
|
||||
void this.requestReload().catch((error: unknown) => Logger(error, LOG_LEVEL_NOTICE));
|
||||
});
|
||||
this.addOnSaved("displayLanguage", () => this.requestCatalogueRefresh());
|
||||
this.addOnSaved("showStatusOnEditor", () => eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR));
|
||||
@@ -545,7 +562,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
);
|
||||
}
|
||||
|
||||
requestReload() {
|
||||
async requestReload(): Promise<void> {
|
||||
if (!(await this.readLocalSettings())) return;
|
||||
const nativeTabIsShown =
|
||||
this.supportsDeclarativeSettings() && this.containerEl !== undefined && this.containerEl.isShown();
|
||||
if (this.isShown || nativeTabIsShown) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS, LOG_LEVEL_NOTICE, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
|
||||
const settingsInitialisationMocks = vi.hoisted(() => ({
|
||||
applySettingsWithInitialisationChoice: vi.fn(),
|
||||
@@ -76,8 +76,6 @@ beforeEach(() => {
|
||||
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockReset();
|
||||
loggerMocks.Logger.mockClear();
|
||||
vi.mocked($msg).mockClear();
|
||||
vi.mocked(compatGlobal.localStorage.getItem).mockReset().mockReturnValue(null);
|
||||
vi.mocked(compatGlobal.localStorage.setItem).mockReset();
|
||||
});
|
||||
|
||||
describe("ObsidianLiveSyncSettingTab passphrase verification", () => {
|
||||
@@ -280,6 +278,7 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
|
||||
},
|
||||
services: {
|
||||
setting: {
|
||||
deviceValues: new MemoryValueStore(),
|
||||
saveSettingData,
|
||||
getDeviceAndVaultName: vi.fn(() => ""),
|
||||
suspendAllSync,
|
||||
@@ -665,6 +664,7 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
},
|
||||
services: {
|
||||
setting: {
|
||||
deviceValues: new MemoryValueStore(),
|
||||
saveSettingData,
|
||||
getDeviceAndVaultName: vi.fn(() => ""),
|
||||
},
|
||||
@@ -692,6 +692,54 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
return { tab, saveSettingData };
|
||||
}
|
||||
|
||||
it("awaits the stored passphrase before initialising the editing buffer", async () => {
|
||||
const { tab } = createSettingsTab();
|
||||
tab.resetEditingSettings();
|
||||
let resolveRead!: (value: string) => void;
|
||||
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockReturnValueOnce(
|
||||
new Promise<string>((resolve) => {
|
||||
resolveRead = resolve;
|
||||
})
|
||||
);
|
||||
const loading = tab.loadSettings();
|
||||
expect(tab.initialSettings).toBeUndefined();
|
||||
resolveRead("stored passphrase");
|
||||
await loading;
|
||||
expect(tab.editingSettings.configPassphrase).toBe("stored passphrase");
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("stored passphrase");
|
||||
});
|
||||
|
||||
it("preserves the loaded settings when device storage cannot be read", async () => {
|
||||
const { tab } = createSettingsTab();
|
||||
await tab.services.setting.deviceValues.setValue("ls-setting-passphrase", "stored passphrase");
|
||||
await tab.loadSettings();
|
||||
const failure = new Error("device storage unavailable");
|
||||
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockRejectedValueOnce(failure);
|
||||
await expect(tab.requestReload()).rejects.toBe(failure);
|
||||
expect(tab.editingSettings.configPassphrase).toBe("stored passphrase");
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("stored passphrase");
|
||||
});
|
||||
|
||||
it("does not let a delayed read overwrite a successfully saved passphrase", async () => {
|
||||
const { tab } = createSettingsTab();
|
||||
await tab.loadSettings();
|
||||
let resolveRead!: (value: string) => void;
|
||||
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockReturnValueOnce(
|
||||
new Promise<string>((resolve) => {
|
||||
resolveRead = resolve;
|
||||
})
|
||||
);
|
||||
const loading = tab.loadSettings();
|
||||
Object.assign(tab.services.setting, { changeConfigurationEncryption: vi.fn(async () => undefined) });
|
||||
tab.editingSettings.configPassphraseStore = "LOCALSTORAGE";
|
||||
tab.editingSettings.configPassphrase = "new passphrase";
|
||||
await tab.saveSettings(["configPassphraseStore", "configPassphrase"]);
|
||||
resolveRead("old passphrase");
|
||||
await loading;
|
||||
tab.reloadAllSettings();
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("new passphrase");
|
||||
});
|
||||
|
||||
it("loads the imperative fallback without a SettingPage runtime export", () => {
|
||||
const { tab } = createSettingsTab();
|
||||
|
||||
@@ -779,7 +827,7 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
it("saves a custom configuration passphrase edit through the setting service", async () => {
|
||||
const { tab } = createSettingsTab();
|
||||
const changeConfigurationEncryption = vi.fn(async () => {
|
||||
vi.mocked(compatGlobal.localStorage.getItem).mockReturnValue("new custom passphrase");
|
||||
await tab.services.setting.deviceValues.setValue("ls-setting-passphrase", "new custom passphrase");
|
||||
});
|
||||
Object.assign(tab.core.services.setting, { changeConfigurationEncryption });
|
||||
const store = "LOCALSTORAGE";
|
||||
@@ -787,13 +835,13 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
tab.initialSettings!.configPassphraseStore = store;
|
||||
tab.editingSettings.configPassphrase = "new custom passphrase";
|
||||
tab.initialSettings!.configPassphrase = "old custom passphrase";
|
||||
vi.mocked(compatGlobal.localStorage.setItem).mockClear();
|
||||
const setValue = vi.spyOn(tab.services.setting.deviceValues, "setValue");
|
||||
|
||||
await tab.saveSettings(["configPassphrase"]);
|
||||
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledWith(store, "new custom passphrase");
|
||||
expect(compatGlobal.localStorage.setItem).not.toHaveBeenCalled();
|
||||
expect(setValue).toHaveBeenCalledOnce();
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("new custom passphrase");
|
||||
});
|
||||
|
||||
@@ -811,7 +859,6 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
tab.initialSettings!.configPassphraseStore = "LOCALSTORAGE";
|
||||
tab.initialSettings!.configPassphrase = "previous passphrase";
|
||||
tab.editingSettings.configPassphrase = "passphrase A";
|
||||
vi.mocked(compatGlobal.localStorage.getItem).mockReturnValue("passphrase A");
|
||||
|
||||
const apply = tab.saveSettings(["configPassphrase"]);
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledWith("LOCALSTORAGE", "passphrase A");
|
||||
@@ -840,13 +887,13 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
tab.editingSettings.configPassphraseStore = editedStore;
|
||||
tab.initialSettings!.configPassphrase = "old custom passphrase";
|
||||
tab.editingSettings.configPassphrase = "new custom passphrase";
|
||||
vi.mocked(compatGlobal.localStorage.setItem).mockClear();
|
||||
const setValue = vi.spyOn(tab.services.setting.deviceValues, "setValue");
|
||||
|
||||
await expect(tab.saveSettings(["configPassphraseStore", "configPassphrase"])).rejects.toBe(failure);
|
||||
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledWith(editedStore, "new custom passphrase");
|
||||
expect(compatGlobal.localStorage.setItem).not.toHaveBeenCalled();
|
||||
expect(setValue).not.toHaveBeenCalled();
|
||||
expect(tab.core.settings.configPassphraseStore).toBe(savedStore);
|
||||
expect(tab.initialSettings!.configPassphraseStore).toBe(savedStore);
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("old custom passphrase");
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("paneAdvanced", () => {
|
||||
};
|
||||
|
||||
paneAdvanced.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(10));
|
||||
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(11));
|
||||
|
||||
expect(addPanel.mock.calls.map(([, heading]) => heading)).toEqual([
|
||||
"Memory cache",
|
||||
@@ -56,6 +56,7 @@ describe("paneAdvanced", () => {
|
||||
panelElements.get("Local Database Tweak"),
|
||||
panelElements.get("Local Database Tweak"),
|
||||
panelElements.get("Local Database Tweak"),
|
||||
panelElements.get("Local Database Tweak"),
|
||||
panelElements.get("Transfer Tweak"),
|
||||
panelElements.get("Transfer Tweak"),
|
||||
panelElements.get("Transfer Tweak"),
|
||||
@@ -67,6 +68,7 @@ describe("paneAdvanced", () => {
|
||||
"hashCacheMaxCount",
|
||||
"chunkSplitterVersion",
|
||||
"customChunkSize",
|
||||
"strictContentCheckOnStartup",
|
||||
"pathNormalizationVersion",
|
||||
"readChunksOnline",
|
||||
"useOnlyLocalChunk",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type ObsidianLiveSyncSettings,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { OPEN_JOINED_DEVICES } from "@/serviceFeatures/joinedDevicesCommand";
|
||||
import { Menu, type ButtonComponent } from "@/deps.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
@@ -101,6 +102,22 @@ export function paneRemoteConfig(
|
||||
paneEl: HTMLElement,
|
||||
{ addPanel }: PageFunctions
|
||||
): void {
|
||||
new Setting(paneEl)
|
||||
.setName($msg("Joined devices"))
|
||||
.setDesc($msg("Review device records for the active CouchDB or Object Storage connection."))
|
||||
.addButton((button) =>
|
||||
button.setButtonText($msg("Review devices")).onClick(async () => {
|
||||
const result = await this.services.UI.commands.invoke(OPEN_JOINED_DEVICES, undefined);
|
||||
if (result.status === "unavailable" || result.value === "unavailable") {
|
||||
this.services.API.addLog(
|
||||
$msg(
|
||||
"Joined devices are available after database startup with an active CouchDB or Object Storage connection."
|
||||
),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
{
|
||||
/* E2EE */
|
||||
const E2EEInitialProps = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
buttonClasses: [] as string[],
|
||||
clickHandlers: [] as Array<() => Promise<void> | void>,
|
||||
clickHandlers: [] as Array<{ setting: string; run: () => Promise<void> | void }>,
|
||||
panels: [] as Array<{ destroy: ReturnType<typeof vi.fn> }>,
|
||||
settingClasses: [] as string[],
|
||||
}));
|
||||
@@ -32,7 +32,10 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
},
|
||||
};
|
||||
|
||||
setName() {
|
||||
settingName = "";
|
||||
|
||||
setName(name: string) {
|
||||
this.settingName = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -41,6 +44,7 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
}
|
||||
|
||||
addButton(callback: (button: unknown) => void) {
|
||||
const setting = this.settingName;
|
||||
const button = {
|
||||
buttonEl: {
|
||||
classList: {
|
||||
@@ -53,7 +57,7 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
return this;
|
||||
},
|
||||
onClick(callback: () => Promise<void> | void) {
|
||||
runtime.clickHandlers.push(callback);
|
||||
runtime.clickHandlers.push({ setting, run: callback });
|
||||
return this;
|
||||
},
|
||||
setButtonText() {
|
||||
@@ -107,6 +111,7 @@ vi.mock("./remoteConfigBuffer.ts", () => ({
|
||||
syncActivatedRemoteSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
import { OPEN_JOINED_DEVICES } from "@/serviceFeatures/joinedDevicesCommand";
|
||||
import { paneRemoteConfig } from "./PaneRemoteConfig.ts";
|
||||
|
||||
function createPanelElement(): HTMLElement {
|
||||
@@ -124,6 +129,18 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("paneRemoteConfig", () => {
|
||||
it("opens joined devices through the shared UI command", async () => {
|
||||
const invoke = vi.fn(async () => ({ status: "handled", value: "closed" }));
|
||||
const host = {
|
||||
editingSettings: { remoteConfigurations: {} },
|
||||
services: { UI: { commands: { invoke } } },
|
||||
};
|
||||
const addPanel = vi.fn(() => ({ then: vi.fn() }));
|
||||
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
await runtime.clickHandlers.find((handler) => handler.setting === "Joined devices")!.run();
|
||||
expect(invoke).toHaveBeenCalledWith(OPEN_JOINED_DEVICES, undefined);
|
||||
});
|
||||
|
||||
it("destroys the E2EE info panel when the settings page lifetime unloads", async () => {
|
||||
const callbacks: Array<() => unknown> = [];
|
||||
const lifetimeComponent = {
|
||||
@@ -187,7 +204,7 @@ describe("paneRemoteConfig", () => {
|
||||
}));
|
||||
|
||||
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
await runtime.clickHandlers[0]();
|
||||
await runtime.clickHandlers.find((handler) => handler.setting === "Configure E2EE")!.run();
|
||||
|
||||
expect(setupManager.onlyE2EEConfiguration).toHaveBeenCalledOnce();
|
||||
expect(host.editingSettings.encryptInternalMetadata).toBe(true);
|
||||
@@ -233,7 +250,7 @@ describe("paneRemoteConfig", () => {
|
||||
}));
|
||||
|
||||
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
await runtime.clickHandlers[0]();
|
||||
await runtime.clickHandlers.find((handler) => handler.setting === "Configure E2EE")!.run();
|
||||
|
||||
expect(host.editingSettings.idDerivationVersion).toBe(1);
|
||||
expect(host.editingSettings.idDerivationKey).toBe(nextIdKey);
|
||||
|
||||
@@ -13,7 +13,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
||||
.setName($msg("Synchronising files"))
|
||||
.setDesc(
|
||||
$msg(
|
||||
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files."
|
||||
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns."
|
||||
)
|
||||
);
|
||||
const syncFilesControl = mount(MultipleRegExpControl, {
|
||||
@@ -32,9 +32,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
||||
|
||||
const nonSyncFilesSetting = new Setting(paneEl)
|
||||
.setName($msg("Non-Synchronising files"))
|
||||
.setDesc(
|
||||
$msg("(RegExp) If this is set, any changes to local and remote files that match this will be skipped.")
|
||||
);
|
||||
.setDesc($msg("(RegExp) Skip changes to matching normal and hidden files, locally and remotely."));
|
||||
|
||||
const nonSyncFilesControl = mount(MultipleRegExpControl, {
|
||||
target: nonSyncFilesSetting.controlEl,
|
||||
|
||||
@@ -171,20 +171,18 @@ export function paneSyncSettings(
|
||||
}
|
||||
});
|
||||
|
||||
void addPanel(
|
||||
paneEl,
|
||||
$msg("obsidianLiveSyncSettingTab.titleUpdateThinning"),
|
||||
undefined,
|
||||
visibleOnly(() => !this.isConfiguredAs("syncMode", "LIVESYNC"))
|
||||
).then((paneEl) => {
|
||||
new Setting(paneEl).autoWireToggle("batchSave");
|
||||
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleUpdateThinning")).then((paneEl) => {
|
||||
const inLiveSyncMode = () => this.isConfiguredAs("syncMode", "LIVESYNC");
|
||||
const batchEnabled = () => this.isConfiguredAs(inLiveSyncMode() ? "batchSaveInLiveSync" : "batchSave", true);
|
||||
new Setting(paneEl).autoWireToggle("batchSave", { onUpdate: visibleOnly(() => !inLiveSyncMode()) });
|
||||
new Setting(paneEl).autoWireToggle("batchSaveInLiveSync", { onUpdate: visibleOnly(inLiveSyncMode) });
|
||||
new Setting(paneEl).autoWireNumeric("batchSaveMinimumDelay", {
|
||||
acceptZero: true,
|
||||
onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)),
|
||||
onUpdate: visibleOnly(batchEnabled),
|
||||
});
|
||||
new Setting(paneEl).autoWireNumeric("batchSaveMaximumDelay", {
|
||||
acceptZero: true,
|
||||
onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)),
|
||||
onUpdate: visibleOnly(batchEnabled),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
|
||||
import type { OnUpdateFunc, PageFunctions } from "./SettingPane";
|
||||
|
||||
const controls = vi.hoisted(() => new Map<string, { onUpdate?: () => { visibility?: boolean } }>());
|
||||
vi.mock("@/common/translation", () => ({ $msg: (key: string) => key }));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/logger", () => ({ Logger: vi.fn() }));
|
||||
vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
LiveSyncSetting: class {
|
||||
autoWireDropDown(key: string, options = {}) {
|
||||
controls.set(key, options);
|
||||
return this;
|
||||
}
|
||||
autoWireToggle(key: string, options = {}) {
|
||||
controls.set(key, options);
|
||||
return this;
|
||||
}
|
||||
autoWireNumeric(key: string, options = {}) {
|
||||
controls.set(key, options);
|
||||
return this;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { paneSyncSettings } from "./PaneSyncSettings";
|
||||
|
||||
function setup(liveSync: boolean, batchSave: boolean, batchSaveInLiveSync: boolean) {
|
||||
const callbacks = new Map<string, (value: string) => Promise<void>>();
|
||||
const panels = new Map<string, OnUpdateFunc | undefined>();
|
||||
const tab = {
|
||||
editingSettings: { ...DEFAULT_SETTINGS, liveSync, batchSave, batchSaveInLiveSync },
|
||||
services: { API: { isMobile: () => false }, control: { applySettings: vi.fn(async () => {}) } },
|
||||
addOnSaved: (key: string, callback: (value: string) => Promise<void>) => callbacks.set(key, callback),
|
||||
isConfiguredAs(key: string, value: unknown) {
|
||||
if (key === "syncMode") {
|
||||
return (
|
||||
(this.editingSettings.liveSync
|
||||
? "LIVESYNC"
|
||||
: this.editingSettings.periodicReplication
|
||||
? "PERIODIC"
|
||||
: "ONEVENTS") === value
|
||||
);
|
||||
}
|
||||
return this.editingSettings[key as keyof typeof this.editingSettings] === value;
|
||||
},
|
||||
saveSettings: vi.fn(async () => {}),
|
||||
requestCatalogueRefresh: vi.fn(),
|
||||
};
|
||||
const functions = {
|
||||
addPanel: (_element: HTMLElement, title: string, _callback: unknown, onUpdate?: OnUpdateFunc) => ({
|
||||
then(callback: (element: HTMLElement) => void) {
|
||||
if (!title.endsWith("titleSynchronizationMethod") && !title.endsWith("titleUpdateThinning")) return;
|
||||
panels.set(title, onUpdate);
|
||||
callback({} as HTMLElement);
|
||||
},
|
||||
}),
|
||||
} as unknown as PageFunctions;
|
||||
paneSyncSettings.call(tab as unknown as ObsidianLiveSyncSettingTab, {} as HTMLElement, functions);
|
||||
return { tab, callbacks, panels };
|
||||
}
|
||||
|
||||
function visible(key: string): boolean {
|
||||
const control = controls.get(key);
|
||||
if (!control) throw new Error(`Missing setting control: ${key}`);
|
||||
return control.onUpdate?.().visibility !== false;
|
||||
}
|
||||
|
||||
beforeEach(() => controls.clear());
|
||||
|
||||
describe("mode-specific database batching settings", () => {
|
||||
it.each([
|
||||
{ liveSync: true, batchSave: true, batchSaveInLiveSync: false, delays: false },
|
||||
{ liveSync: true, batchSave: false, batchSaveInLiveSync: true, delays: true },
|
||||
{ liveSync: false, batchSave: true, batchSaveInLiveSync: false, delays: true },
|
||||
{ liveSync: false, batchSave: false, batchSaveInLiveSync: true, delays: false },
|
||||
])("shows the current mode choice and its shared delays: %j", ({ delays, ...settings }) => {
|
||||
const { panels } = setup(settings.liveSync, settings.batchSave, settings.batchSaveInLiveSync);
|
||||
expect(panels.get("obsidianLiveSyncSettingTab.titleUpdateThinning")?.().visibility).not.toBe(false);
|
||||
expect(visible("batchSave")).toBe(!settings.liveSync);
|
||||
expect(visible("batchSaveInLiveSync")).toBe(settings.liveSync);
|
||||
expect(visible("batchSaveMinimumDelay")).toBe(delays);
|
||||
expect(visible("batchSaveMaximumDelay")).toBe(delays);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[true, false],
|
||||
[false, true],
|
||||
])("retains both batching choices across mode changes: %j", async (batchSave, batchSaveInLiveSync) => {
|
||||
const { tab, callbacks } = setup(false, batchSave, batchSaveInLiveSync);
|
||||
const switchMode = callbacks.get("syncMode")!;
|
||||
for (const mode of ["LIVESYNC", "PERIODIC", "ONEVENTS", "LIVESYNC"]) {
|
||||
await switchMode(mode);
|
||||
expect(tab.editingSettings).toMatchObject({ batchSave, batchSaveInLiveSync });
|
||||
}
|
||||
expect(tab.services.control.applySettings).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ const rangeMessages = {
|
||||
};
|
||||
|
||||
describe("Advanced setting specifications", () => {
|
||||
it("lists only the ten controls currently exposed by the Advanced page", () => {
|
||||
it("lists only the eleven controls currently exposed by the Advanced page", () => {
|
||||
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => true });
|
||||
|
||||
expect(groups.map(({ heading }) => heading)).toEqual([
|
||||
@@ -30,6 +30,7 @@ describe("Advanced setting specifications", () => {
|
||||
"hashCacheMaxCount",
|
||||
"chunkSplitterVersion",
|
||||
"customChunkSize",
|
||||
"strictContentCheckOnStartup",
|
||||
"pathNormalizationVersion",
|
||||
"readChunksOnline",
|
||||
"useOnlyLocalChunk",
|
||||
@@ -40,6 +41,17 @@ describe("Advanced setting specifications", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("exposes strict content checking as an ungated Advanced toggle", () => {
|
||||
const spec = createAdvancedSettingSpecGroups({ isCouchDB: () => false })
|
||||
.find(({ heading }) => heading === "Local Database Tweak")
|
||||
?.items.find(({ key }) => key === "strictContentCheckOnStartup");
|
||||
if (!spec) throw new Error("Strict content checking is not exposed on the Advanced page");
|
||||
|
||||
expect(spec.control).toEqual({ type: "toggle" });
|
||||
expect(spec.visible).toBeUndefined();
|
||||
expect(spec.disabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps path identity and the four remote chunk controls CouchDB-only", () => {
|
||||
let couchDB = false;
|
||||
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => couchDB });
|
||||
|
||||
@@ -86,6 +86,7 @@ describe("settings page catalogue", () => {
|
||||
"hashCacheMaxCount",
|
||||
"chunkSplitterVersion",
|
||||
"customChunkSize",
|
||||
"strictContentCheckOnStartup",
|
||||
"pathNormalizationVersion",
|
||||
"readChunksOnline",
|
||||
"useOnlyLocalChunk",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
@@ -37,11 +38,6 @@ import { decodeSettingsFromQRCodeData } from "@vrtmrz/livesync-commonlib/compat/
|
||||
import { SetupManager, UserMode } from "./SetupManager";
|
||||
|
||||
class TestSettingService extends SettingService<ServiceContext> {
|
||||
protected setItem(_key: string, _value: string): void {}
|
||||
protected getItem(_key: string): string {
|
||||
return "";
|
||||
}
|
||||
protected deleteItem(_key: string): void {}
|
||||
protected saveData(_setting: ObsidianLiveSyncSettings): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -65,6 +61,7 @@ function createLegacyRemoteSetting(): ObsidianLiveSyncSettings {
|
||||
|
||||
function createSetupManager() {
|
||||
const setting = new TestSettingService(new ServiceContext(), {
|
||||
deviceValues: new MemoryValueStore(),
|
||||
APIService: {
|
||||
getSystemVaultName: vi.fn(() => "vault"),
|
||||
getAppID: vi.fn(() => "app"),
|
||||
@@ -172,10 +169,12 @@ describe("SetupManager", () => {
|
||||
const { manager, dialogManager } = createSetupManager();
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("configure-manually");
|
||||
const configureManually = vi.spyOn(manager, "onConfigureManually").mockResolvedValue(true);
|
||||
const newVaultSettings = createNewVaultSettings();
|
||||
|
||||
await manager.onOnboard(UserMode.NewUser);
|
||||
|
||||
expect(configureManually).toHaveBeenCalledWith(createNewVaultSettings(), UserMode.NewUser);
|
||||
expect(newVaultSettings.customChunkSize).toBe(0);
|
||||
expect(configureManually).toHaveBeenCalledWith(newVaultSettings, UserMode.NewUser);
|
||||
});
|
||||
|
||||
it("compatibility: normalises imported flat remote settings from a Setup URI before applying", async () => {
|
||||
@@ -487,8 +486,15 @@ describe("SetupManager", () => {
|
||||
expect(applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves modern profiles, display names, and the active selection from a Setup URI", async () => {
|
||||
it.each([
|
||||
{ importedSize: 0, expectedSize: 0 },
|
||||
{ importedSize: undefined, expectedSize: 60 },
|
||||
])("preserves modern Setup URI profiles and adopts the supplied chunk size $importedSize", async ({ importedSize, expectedSize }) => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
...setting.currentSettings(),
|
||||
customChunkSize: 60,
|
||||
};
|
||||
const imported = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteConfigurations: {
|
||||
@@ -507,6 +513,8 @@ describe("SetupManager", () => {
|
||||
},
|
||||
activeConfigurationId: "archive",
|
||||
} as ObsidianLiveSyncSettings;
|
||||
if (importedSize === undefined) delete (imported as Partial<ObsidianLiveSyncSettings>).customChunkSize;
|
||||
else imported.customChunkSize = importedSize;
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce(imported)
|
||||
.mockResolvedValueOnce("compatible-existing-user");
|
||||
@@ -514,6 +522,7 @@ describe("SetupManager", () => {
|
||||
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://modern-settings");
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(current.customChunkSize).toBe(expectedSize);
|
||||
expect(current.remoteConfigurations).toEqual(imported.remoteConfigurations);
|
||||
expect(current.activeConfigurationId).toBe("archive");
|
||||
expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false);
|
||||
@@ -523,6 +532,7 @@ describe("SetupManager", () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
...setting.currentSettings(),
|
||||
customChunkSize: 60,
|
||||
isConfigured: true,
|
||||
remoteConfigurations: {
|
||||
existing: {
|
||||
@@ -554,6 +564,7 @@ describe("SetupManager", () => {
|
||||
await manager.onCouchDBManualSetup(UserMode.ExistingUser, setting.currentSettings());
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(current.customChunkSize).toBe(60);
|
||||
expect(current.remoteConfigurations.existing).toBeDefined();
|
||||
expect(Object.keys(current.remoteConfigurations)).toHaveLength(2);
|
||||
expect(current.activeConfigurationId).not.toBe("existing");
|
||||
@@ -662,6 +673,7 @@ describe("SetupManager", () => {
|
||||
await manager.onP2PManualSetup(UserMode.NewUser, setting.currentSettings());
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(current.customChunkSize).toBe(0);
|
||||
expect(Object.keys(current.remoteConfigurations)).toHaveLength(1);
|
||||
expect(current.activeConfigurationId).not.toBe("");
|
||||
expect(current.P2P_ActiveRemoteConfigurationId).toBe(current.activeConfigurationId);
|
||||
|
||||
@@ -52,26 +52,48 @@
|
||||
const context = getDialogContext();
|
||||
let error = $state("");
|
||||
let connectionPathResetNotice = $state(false);
|
||||
let peerNameReady = $state(false);
|
||||
let mounted = false;
|
||||
const hasValidTurnServer = $derived(hasP2PTurnConfiguration(syncSetting));
|
||||
type Props = GuestDialogProps<SetupRemoteP2PResultType, SetupRemoteP2PInitialData>;
|
||||
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
let connectionProbe: SetupRemoteP2PInitialData["connectionProbe"] | undefined;
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
const initialData = getInitialData?.();
|
||||
connectionProbe = initialData?.connectionProbe;
|
||||
const initialSettings = initialData?.settings;
|
||||
if (initialSettings) {
|
||||
syncSetting = pickP2PSyncSettings(initialSettings);
|
||||
}
|
||||
const initialPeerName = (initialSettings?.P2P_DevicePeerName ?? "").trim();
|
||||
const savedPeerName = initialSettings?.P2P_DevicePeerName;
|
||||
const initialPeerName = typeof savedPeerName === "string" ? savedPeerName.trim() : "";
|
||||
if (initialPeerName !== "") {
|
||||
return;
|
||||
peerNameReady = true;
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}
|
||||
const cachedPeerName = context.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME);
|
||||
if (cachedPeerName) {
|
||||
syncSetting.P2P_DevicePeerName = cachedPeerName as string;
|
||||
void context.services.config.getValue(SETTING_KEY_P2P_DEVICE_NAME).then(
|
||||
(cachedPeerName) => {
|
||||
if (mounted) {
|
||||
if (typeof cachedPeerName === "string" && cachedPeerName) {
|
||||
syncSetting.P2P_DevicePeerName = cachedPeerName;
|
||||
}
|
||||
peerNameReady = true;
|
||||
}
|
||||
},
|
||||
(loadError: unknown) => {
|
||||
if (mounted) {
|
||||
error = loadError instanceof Error ? loadError.message : String(loadError);
|
||||
peerNameReady = true;
|
||||
}
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
});
|
||||
function generateSetting() {
|
||||
const connSetting: P2PSyncSetting = {
|
||||
@@ -193,6 +215,7 @@
|
||||
}
|
||||
|
||||
async function checkAndCommit() {
|
||||
if (!peerNameReady) return;
|
||||
error = "";
|
||||
try {
|
||||
error = (await checkConnection()) || "";
|
||||
@@ -207,6 +230,7 @@
|
||||
}
|
||||
}
|
||||
function commit() {
|
||||
if (!peerNameReady) return;
|
||||
error = validateManagedTurnSettings(syncSetting) ?? "";
|
||||
if (error) return;
|
||||
const setting = pickP2PSyncSettings(generateSetting());
|
||||
@@ -217,6 +241,7 @@
|
||||
}
|
||||
const canProceed = $derived.by(() => {
|
||||
return (
|
||||
peerNameReady &&
|
||||
syncSetting.P2P_relays.trim() !== "" &&
|
||||
syncSetting.P2P_roomID.trim() !== "" &&
|
||||
syncSetting.P2P_passphrase.trim() !== "" &&
|
||||
@@ -291,6 +316,7 @@
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
disabled={!peerNameReady}
|
||||
bind:value={syncSetting.P2P_DevicePeerName}
|
||||
/>
|
||||
</InputRow>
|
||||
@@ -385,7 +411,11 @@
|
||||
disabled={!canProceed}
|
||||
commit={() => checkAndCommit()}
|
||||
/>
|
||||
<Decision title={translateMessage("Continue anyway")} commit={() => commit()} />
|
||||
<Decision
|
||||
title={translateMessage("Continue anyway")}
|
||||
disabled={!peerNameReady}
|
||||
commit={() => commit()}
|
||||
/>
|
||||
<Decision title={translateMessage("Cancel")} commit={() => cancel()} />
|
||||
</UserDecisions>
|
||||
{/if}
|
||||
@@ -10,6 +10,15 @@ export type DisposableReactiveValue<T> = ReactiveValue<T> & {
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
export type PaddedCounterLabel = DisposableReactiveValue<string> & {
|
||||
readonly count: ReactiveValue<number | undefined>;
|
||||
};
|
||||
|
||||
export type ChunkFetchCounterLabel = DisposableReactiveValue<string> & {
|
||||
readonly initialCount: ReactiveValue<number | undefined>;
|
||||
readonly retryingCount: ReactiveValue<number | undefined>;
|
||||
};
|
||||
|
||||
function asDisposableReactiveValue<T>(value: ReactiveSource<T>, dispose: () => void): DisposableReactiveValue<T> {
|
||||
return {
|
||||
get value() {
|
||||
@@ -92,9 +101,10 @@ export function createPaddedCounterLabel(
|
||||
source: ReactiveValue<number>,
|
||||
mark: string,
|
||||
inactiveLingerMs = STATUS_COUNTER_INACTIVE_LINGER_MS
|
||||
): DisposableReactiveValue<string> {
|
||||
): PaddedCounterLabel {
|
||||
const linger = Math.max(0, inactiveLingerMs);
|
||||
const formatted = reactiveSource("");
|
||||
const displayedCount = reactiveSource<number | undefined>(undefined);
|
||||
let maximumLength = 1;
|
||||
let clearTimer: number | undefined;
|
||||
let disposed = false;
|
||||
@@ -114,22 +124,39 @@ export function createPaddedCounterLabel(
|
||||
cancelClear();
|
||||
const count = source.value;
|
||||
formatted.value = format(count);
|
||||
displayedCount.value = count;
|
||||
if (count !== 0) return;
|
||||
clearTimer = compatGlobal.setTimeout(() => {
|
||||
clearTimer = undefined;
|
||||
if (disposed) return;
|
||||
formatted.value = "";
|
||||
displayedCount.value = undefined;
|
||||
maximumLength = 1;
|
||||
}, linger);
|
||||
};
|
||||
|
||||
source.onChanged(update);
|
||||
return asDisposableReactiveValue(formatted, () => {
|
||||
const display = asDisposableReactiveValue(formatted, () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
cancelClear();
|
||||
source.offChanged(update);
|
||||
});
|
||||
return {
|
||||
get value() {
|
||||
return display.value;
|
||||
},
|
||||
onChanged(handler) {
|
||||
display.onChanged(handler);
|
||||
},
|
||||
offChanged(handler) {
|
||||
display.offChanged(handler);
|
||||
},
|
||||
count: displayedCount,
|
||||
dispose() {
|
||||
display.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,7 +165,7 @@ export function createPaddedCounterLabel(
|
||||
*/
|
||||
export function createChunkFetchCounterLabel(
|
||||
source: ReactiveValue<{ initial: number; retrying: number }>
|
||||
): DisposableReactiveValue<string> {
|
||||
): ChunkFetchCounterLabel {
|
||||
const initialCount = reactiveSource(0);
|
||||
const retryingCount = reactiveSource(0);
|
||||
const initialLabel = createPaddedCounterLabel(initialCount, "🛄");
|
||||
@@ -168,7 +195,7 @@ export function createChunkFetchCounterLabel(
|
||||
source.onChanged(updateCounts);
|
||||
updateCounts();
|
||||
|
||||
return asDisposableReactiveValue(formatted, () => {
|
||||
const display = asDisposableReactiveValue(formatted, () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
source.offChanged(updateCounts);
|
||||
@@ -177,4 +204,20 @@ export function createChunkFetchCounterLabel(
|
||||
initialLabel.dispose();
|
||||
retryingLabel.dispose();
|
||||
});
|
||||
return {
|
||||
get value() {
|
||||
return display.value;
|
||||
},
|
||||
onChanged(handler) {
|
||||
display.onChanged(handler);
|
||||
},
|
||||
offChanged(handler) {
|
||||
display.offChanged(handler);
|
||||
},
|
||||
initialCount: initialLabel.count,
|
||||
retryingCount: retryingLabel.count,
|
||||
dispose() {
|
||||
display.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -111,6 +111,24 @@ describe("createPaddedCounterLabel", () => {
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it("exposes the numeric value while retaining zero through the existing linger", () => {
|
||||
const source = reactiveSource(0);
|
||||
const display = createPaddedCounterLabel(source, "📥");
|
||||
|
||||
expect(display.count.value).toBeUndefined();
|
||||
source.value = 9;
|
||||
expect(display.count.value).toBe(9);
|
||||
source.value = 0;
|
||||
expect(display.count.value).toBe(0);
|
||||
|
||||
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS - 1);
|
||||
expect(display.count.value).toBe(0);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(display.count.value).toBeUndefined();
|
||||
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it("cancels the pending clear when counter activity resumes", () => {
|
||||
const source = reactiveSource(0);
|
||||
const display = createPaddedCounterLabel(source, "📄");
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
const INACTIVE_CLASS = "livesync-stat-inactive";
|
||||
|
||||
export type StatusBarPart = {
|
||||
key: string;
|
||||
text: string;
|
||||
value?: number;
|
||||
suffix?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type StatusBarRenderer = {
|
||||
render(parts: readonly StatusBarPart[]): void;
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
type RenderedItem = {
|
||||
element: HTMLSpanElement;
|
||||
prefix: Text;
|
||||
value: HTMLSpanElement;
|
||||
valueText: Text;
|
||||
suffix: Text;
|
||||
widestValueLength: number;
|
||||
};
|
||||
|
||||
export function createStatusBarRenderer(parent: HTMLElement): StatusBarRenderer {
|
||||
const items = new Map<string, RenderedItem>();
|
||||
let disposed = false;
|
||||
|
||||
const setText = (node: Text, text: string) => {
|
||||
if (node.data !== text) node.data = text;
|
||||
};
|
||||
|
||||
const setHidden = (element: HTMLElement, hidden: boolean) => {
|
||||
if (element.hidden !== hidden) element.hidden = hidden;
|
||||
};
|
||||
|
||||
const setInactive = (element: HTMLElement, inactive: boolean) => {
|
||||
if (element.classList.contains(INACTIVE_CLASS) === inactive) return;
|
||||
element.classList.toggle(INACTIVE_CLASS, inactive);
|
||||
if (inactive) element.setAttribute("aria-hidden", "true");
|
||||
else element.removeAttribute("aria-hidden");
|
||||
};
|
||||
|
||||
const setWidth = (element: HTMLElement, width: string) => {
|
||||
if (element.style.getPropertyValue("--livesync-stat-width") !== width) {
|
||||
element.setCssProps({ "--livesync-stat-width": width });
|
||||
}
|
||||
};
|
||||
|
||||
const createItem = (key: string): RenderedItem => {
|
||||
const document = parent.ownerDocument;
|
||||
const element = parent.createSpan();
|
||||
element.className = "livesync-stat-item livesync-stat-" + key;
|
||||
|
||||
const prefix = document.createTextNode("");
|
||||
const value = element.createSpan();
|
||||
value.className = "livesync-stat-value";
|
||||
const valueText = document.createTextNode("");
|
||||
value.appendChild(valueText);
|
||||
value.hidden = true;
|
||||
const suffix = document.createTextNode("");
|
||||
|
||||
element.appendChild(prefix);
|
||||
element.appendChild(value);
|
||||
element.appendChild(suffix);
|
||||
|
||||
const item = { element, prefix, value, valueText, suffix, widestValueLength: 0 };
|
||||
items.set(key, item);
|
||||
return item;
|
||||
};
|
||||
|
||||
const updateItem = (item: RenderedItem, part: StatusBarPart) => {
|
||||
setText(item.prefix, part.text);
|
||||
const title = part.title ?? "";
|
||||
if (item.element.title !== title) item.element.title = title;
|
||||
|
||||
if (part.value === undefined) {
|
||||
setText(item.valueText, "");
|
||||
setText(item.suffix, "");
|
||||
setHidden(item.value, true);
|
||||
setWidth(item.value, "");
|
||||
item.widestValueLength = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedValue = String(part.value);
|
||||
setText(item.valueText, formattedValue);
|
||||
setText(item.suffix, part.suffix ?? "");
|
||||
setHidden(item.value, false);
|
||||
|
||||
if (formattedValue.length > item.widestValueLength) {
|
||||
item.widestValueLength = formattedValue.length;
|
||||
const width = item.widestValueLength + "ch";
|
||||
setWidth(item.value, width);
|
||||
}
|
||||
};
|
||||
|
||||
const reorderIfNeeded = (visibleItems: readonly RenderedItem[]) => {
|
||||
const desiredOrder = visibleItems.map((item) => item.element);
|
||||
const visibleElements = new Set(desiredOrder);
|
||||
const currentOrder = Array.from(parent.childNodes).filter((node): node is HTMLSpanElement =>
|
||||
visibleElements.has(node as HTMLSpanElement)
|
||||
);
|
||||
if (
|
||||
currentOrder.length === desiredOrder.length &&
|
||||
currentOrder.every((node, index) => node === desiredOrder[index])
|
||||
)
|
||||
return;
|
||||
|
||||
let reference: ChildNode | null = currentOrder[0] ?? null;
|
||||
for (const element of desiredOrder) {
|
||||
if (element !== reference) parent.insertBefore(element, reference);
|
||||
const childNodes = Array.from(parent.childNodes);
|
||||
const elementIndex = childNodes.indexOf(element);
|
||||
reference =
|
||||
childNodes.slice(elementIndex + 1).find((node) => visibleElements.has(node as HTMLSpanElement)) ?? null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
render(parts) {
|
||||
if (disposed) return;
|
||||
|
||||
const visibleItems: RenderedItem[] = [];
|
||||
const visibleKeys = new Set<string>();
|
||||
for (const part of parts) {
|
||||
let item = items.get(part.key);
|
||||
if (!item) item = createItem(part.key);
|
||||
if (item.element.classList.contains(INACTIVE_CLASS)) item.widestValueLength = 0;
|
||||
updateItem(item, part);
|
||||
setInactive(item.element, false);
|
||||
visibleItems.push(item);
|
||||
visibleKeys.add(part.key);
|
||||
}
|
||||
|
||||
for (const [key, item] of items) {
|
||||
if (visibleKeys.has(key)) continue;
|
||||
setInactive(item.element, true);
|
||||
}
|
||||
|
||||
reorderIfNeeded(visibleItems);
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
for (const item of items.values()) {
|
||||
if (item.element.parentNode === parent) parent.removeChild(item.element);
|
||||
}
|
||||
items.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createStatusBarRenderer, type StatusBarPart } from "./StatusBarRenderer.ts";
|
||||
|
||||
type TestNode = TestElement | TestTextNode;
|
||||
|
||||
class TestDocument {
|
||||
mutations = 0;
|
||||
|
||||
createElement(tagName: string): TestElement {
|
||||
return new TestElement(this, tagName);
|
||||
}
|
||||
|
||||
createTextNode(data: string): TestTextNode {
|
||||
return new TestTextNode(this, data);
|
||||
}
|
||||
}
|
||||
|
||||
class TestTextNode {
|
||||
readonly nodeType = 3;
|
||||
parentNode: TestElement | undefined;
|
||||
private currentData: string;
|
||||
|
||||
constructor(
|
||||
private readonly document: TestDocument,
|
||||
data: string
|
||||
) {
|
||||
this.currentData = data;
|
||||
}
|
||||
|
||||
get data(): string {
|
||||
return this.currentData;
|
||||
}
|
||||
|
||||
set data(value: string) {
|
||||
this.document.mutations++;
|
||||
this.currentData = value;
|
||||
}
|
||||
|
||||
get textContent(): string {
|
||||
return this.currentData;
|
||||
}
|
||||
}
|
||||
|
||||
class TestStyle {
|
||||
private properties = new Map<string, string>();
|
||||
|
||||
constructor(private readonly document: TestDocument) {}
|
||||
|
||||
getPropertyValue(name: string): string {
|
||||
return this.properties.get(name) ?? "";
|
||||
}
|
||||
|
||||
setProperty(name: string, value: string) {
|
||||
this.document.mutations++;
|
||||
this.properties.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
class TestElement {
|
||||
readonly nodeType = 1;
|
||||
readonly ownerDocument: Document;
|
||||
readonly childNodes: TestNode[] = [];
|
||||
parentNode: TestElement | undefined;
|
||||
readonly style: TestStyle;
|
||||
private currentClassName = "";
|
||||
private currentHidden = false;
|
||||
private currentTitle = "";
|
||||
private attributes = new Map<string, string>();
|
||||
readonly classList = {
|
||||
contains: (token: string): boolean => this.currentClassName.split(" ").includes(token),
|
||||
toggle: (token: string, force: boolean): boolean => {
|
||||
const tokens = this.currentClassName.split(" ").filter(Boolean);
|
||||
const present = tokens.includes(token);
|
||||
if (present !== force) {
|
||||
this.className = force
|
||||
? [...tokens, token].join(" ")
|
||||
: tokens.filter((item) => item !== token).join(" ");
|
||||
}
|
||||
return force;
|
||||
},
|
||||
};
|
||||
|
||||
getAttribute(name: string): string | null {
|
||||
return this.attributes.get(name) ?? null;
|
||||
}
|
||||
|
||||
setAttribute(name: string, value: string) {
|
||||
this.document.mutations++;
|
||||
this.attributes.set(name, value);
|
||||
}
|
||||
|
||||
removeAttribute(name: string) {
|
||||
if (this.attributes.delete(name)) this.document.mutations++;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly document: TestDocument,
|
||||
readonly tagName: string
|
||||
) {
|
||||
this.ownerDocument = document as unknown as Document;
|
||||
this.style = new TestStyle(document);
|
||||
}
|
||||
|
||||
get className(): string {
|
||||
return this.currentClassName;
|
||||
}
|
||||
|
||||
set className(value: string) {
|
||||
this.document.mutations++;
|
||||
this.currentClassName = value;
|
||||
}
|
||||
|
||||
get hidden(): boolean {
|
||||
return this.currentHidden;
|
||||
}
|
||||
|
||||
set hidden(value: boolean) {
|
||||
this.document.mutations++;
|
||||
this.currentHidden = value;
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
return this.currentTitle;
|
||||
}
|
||||
|
||||
set title(value: string) {
|
||||
this.document.mutations++;
|
||||
this.currentTitle = value;
|
||||
}
|
||||
|
||||
get children(): TestElement[] {
|
||||
return this.childNodes.filter((node): node is TestElement => node instanceof TestElement);
|
||||
}
|
||||
|
||||
get textContent(): string {
|
||||
return this.childNodes.map((node) => node.textContent ?? "").join("");
|
||||
}
|
||||
|
||||
createSpan(): TestElement {
|
||||
return this.appendChild(this.document.createElement("span"));
|
||||
}
|
||||
|
||||
setCssProps(properties: Record<string, string>) {
|
||||
for (const [name, value] of Object.entries(properties)) this.style.setProperty(name, value);
|
||||
}
|
||||
|
||||
appendChild<T extends TestNode>(node: T): T {
|
||||
if (node.parentNode) {
|
||||
node.parentNode.removeChild(node);
|
||||
}
|
||||
this.childNodes.push(node);
|
||||
node.parentNode = this;
|
||||
this.document.mutations++;
|
||||
return node;
|
||||
}
|
||||
|
||||
insertBefore<T extends TestNode>(node: T, reference: TestNode | null): T {
|
||||
if (node === reference) return node;
|
||||
if (node.parentNode) {
|
||||
node.parentNode.removeChild(node);
|
||||
}
|
||||
const index = reference ? this.childNodes.indexOf(reference) : -1;
|
||||
if (index < 0) {
|
||||
this.childNodes.push(node);
|
||||
} else {
|
||||
this.childNodes.splice(index, 0, node);
|
||||
}
|
||||
node.parentNode = this;
|
||||
this.document.mutations++;
|
||||
return node;
|
||||
}
|
||||
|
||||
removeChild<T extends TestNode>(node: T): T {
|
||||
const index = this.childNodes.indexOf(node);
|
||||
if (index < 0) throw new Error("Node is not a child");
|
||||
this.childNodes.splice(index, 1);
|
||||
node.parentNode = undefined;
|
||||
this.document.mutations++;
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
function createFixture() {
|
||||
const document = new TestDocument();
|
||||
const root = new TestElement(document, "div");
|
||||
return { document, root, parent: root as unknown as HTMLElement };
|
||||
}
|
||||
|
||||
function findItem(root: TestElement, key: string): TestElement {
|
||||
const item = root.children.find((child) => child.className.split(" ").includes("livesync-stat-" + key));
|
||||
if (!item) throw new Error("Missing item: " + key);
|
||||
return item;
|
||||
}
|
||||
|
||||
function findValue(item: TestElement): TestElement {
|
||||
const value = item.children.find((child) => child.className.split(" ").includes("livesync-stat-value"));
|
||||
if (!value) throw new Error("Missing numeric value");
|
||||
return value;
|
||||
}
|
||||
|
||||
function visibleItems(root: TestElement): TestElement[] {
|
||||
return root.children.filter(
|
||||
(child) => !child.classList.contains("livesync-stat-inactive") && child.classList.contains("livesync-stat-item")
|
||||
);
|
||||
}
|
||||
|
||||
describe("createStatusBarRenderer", () => {
|
||||
it("renders complete text safely and gives each item an independent class hook", () => {
|
||||
const { root, parent } = createFixture();
|
||||
const renderer = createStatusBarRenderer(parent);
|
||||
|
||||
renderer.render([
|
||||
{ key: "sync", text: "<sync>" },
|
||||
{ key: "queue", text: " (queued: ", value: 2, suffix: ")" },
|
||||
]);
|
||||
|
||||
const sync = findItem(root, "sync");
|
||||
const queue = findItem(root, "queue");
|
||||
expect(sync.className.split(" ")).toEqual(["livesync-stat-item", "livesync-stat-sync"]);
|
||||
expect(sync.textContent).toBe("<sync>");
|
||||
expect(queue.className.split(" ")).toContain("livesync-stat-queue");
|
||||
expect(queue.textContent).toBe(" (queued: 2)");
|
||||
expect(findValue(queue).className).toBe("livesync-stat-value");
|
||||
|
||||
renderer.render([{ key: "queue", text: " (queued: ", value: 3, suffix: ")" }]);
|
||||
expect(sync.classList.contains("livesync-stat-inactive")).toBe(true);
|
||||
expect(sync.hidden).toBe(false);
|
||||
expect(sync.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(queue.classList.contains("livesync-stat-inactive")).toBe(false);
|
||||
expect(queue.textContent).toBe(" (queued: 3)");
|
||||
renderer.render([{ key: "queue", text: "Queue idle" }]);
|
||||
expect(queue.textContent).toBe("Queue idle");
|
||||
expect(findValue(queue).hidden).toBe(true);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it("does not mutate the DOM when the same parts are rendered again", () => {
|
||||
const { document, parent } = createFixture();
|
||||
const renderer = createStatusBarRenderer(parent);
|
||||
const parts: StatusBarPart[] = [
|
||||
{ key: "pending", text: " pending ", value: 7, suffix: " files", title: "Pending" },
|
||||
];
|
||||
|
||||
renderer.render(parts);
|
||||
const mutations = document.mutations;
|
||||
renderer.render(parts);
|
||||
|
||||
expect(document.mutations).toBe(mutations);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it("updates values while retaining the widest visible numeric width", () => {
|
||||
const { root, parent } = createFixture();
|
||||
const renderer = createStatusBarRenderer(parent);
|
||||
|
||||
renderer.render([{ key: "count", text: "(", value: 9, suffix: ")" }]);
|
||||
const item = findItem(root, "count");
|
||||
const value = findValue(item);
|
||||
expect(item.textContent).toBe("(9)");
|
||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("1ch");
|
||||
|
||||
renderer.render([{ key: "count", text: "(", value: 123, suffix: ")" }]);
|
||||
expect(value.textContent).toBe("123");
|
||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("3ch");
|
||||
|
||||
renderer.render([{ key: "count", text: "(", value: 4, suffix: ")" }]);
|
||||
expect(value.textContent).toBe("4");
|
||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("3ch");
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it("retains omitted content and width, then resets width when the same item returns", () => {
|
||||
const { root, parent } = createFixture();
|
||||
const renderer = createStatusBarRenderer(parent);
|
||||
|
||||
renderer.render([{ key: "count", text: " ", value: 1234 }]);
|
||||
const item = findItem(root, "count");
|
||||
const value = findValue(item);
|
||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("4ch");
|
||||
|
||||
renderer.render([]);
|
||||
expect(item.classList.contains("livesync-stat-inactive")).toBe(true);
|
||||
expect(item.hidden).toBe(false);
|
||||
expect(item.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(item.textContent).toBe(" 1234");
|
||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("4ch");
|
||||
|
||||
renderer.render([{ key: "count", text: " ", value: 5 }]);
|
||||
expect(findItem(root, "count")).toBe(item);
|
||||
expect(item.classList.contains("livesync-stat-inactive")).toBe(false);
|
||||
expect(item.getAttribute("aria-hidden")).toBeNull();
|
||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("1ch");
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it("keeps inactive items in place when other items appear and does not repeat inactive mutations", () => {
|
||||
const { document, root, parent } = createFixture();
|
||||
const renderer = createStatusBarRenderer(parent);
|
||||
const first = { key: "first", text: "first " };
|
||||
const count = { key: "count", text: "count ", value: 1234 };
|
||||
const last = { key: "last", text: "last" };
|
||||
renderer.render([first, count, last]);
|
||||
const initialOrder = [...root.children];
|
||||
const omitted = findItem(root, "count");
|
||||
|
||||
renderer.render([first, last]);
|
||||
expect(root.children.map((item) => initialOrder.indexOf(item))).toEqual([0, 1, 2]);
|
||||
expect(visibleItems(root)).toEqual([initialOrder[0], initialOrder[2]]);
|
||||
const mutations = document.mutations;
|
||||
renderer.render([first, last]);
|
||||
expect(document.mutations).toBe(mutations);
|
||||
|
||||
const added = { key: "added", text: "added " };
|
||||
renderer.render([first, added, last]);
|
||||
expect(root.children.map((item) => item.textContent)).toEqual(["first ", "count 1234", "added ", "last"]);
|
||||
expect(omitted.textContent).toBe("count 1234");
|
||||
expect(findValue(omitted).style.getPropertyValue("--livesync-stat-width")).toBe("4ch");
|
||||
|
||||
renderer.render([first, count, added, last]);
|
||||
expect(findItem(root, "count")).toBe(omitted);
|
||||
expect(visibleItems(root)).toEqual(root.children);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it("reorders existing nodes without replacing them", () => {
|
||||
const { root, parent } = createFixture();
|
||||
const renderer = createStatusBarRenderer(parent);
|
||||
renderer.render([
|
||||
{ key: "first", text: "first " },
|
||||
{ key: "second", text: "second " },
|
||||
]);
|
||||
const first = findItem(root, "first");
|
||||
const second = findItem(root, "second");
|
||||
|
||||
renderer.render([
|
||||
{ key: "second", text: "second " },
|
||||
{ key: "first", text: "first " },
|
||||
]);
|
||||
|
||||
expect(visibleItems(root)).toEqual([second, first]);
|
||||
expect(findItem(root, "first")).toBe(first);
|
||||
expect(findItem(root, "second")).toBe(second);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it("removes its nodes on dispose and ignores later renders", () => {
|
||||
const { root, parent } = createFixture();
|
||||
const externalNode = new TestElement(new TestDocument(), "strong");
|
||||
root.appendChild(externalNode);
|
||||
const renderer = createStatusBarRenderer(parent);
|
||||
renderer.render([{ key: "sync", text: "sync" }]);
|
||||
const item = findItem(root, "sync");
|
||||
|
||||
renderer.dispose();
|
||||
const childCountAfterDispose = root.childNodes.length;
|
||||
renderer.dispose();
|
||||
renderer.render([{ key: "later", text: "later" }]);
|
||||
|
||||
expect(item.parentNode).toBeUndefined();
|
||||
expect(root.childNodes).toEqual([externalNode]);
|
||||
expect(root.childNodes).toHaveLength(childCountAfterDispose);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,22 @@ import { initialiseWorkerModule } from "@vrtmrz/livesync-commonlib/compat/worker
|
||||
import { manifestVersion, packageVersion } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvVars";
|
||||
import { VaultScanResults } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
|
||||
const OBSIDIAN_PLATFORMS = new Set([
|
||||
"android-app",
|
||||
"ios",
|
||||
"macos",
|
||||
"mobile-app",
|
||||
"mobile",
|
||||
"safari",
|
||||
"desktop",
|
||||
"desktop-app",
|
||||
"unknown-obsidian",
|
||||
]);
|
||||
|
||||
function isObsidianPlatform(platform: string): boolean {
|
||||
return OBSIDIAN_PLATFORMS.has(platform);
|
||||
}
|
||||
|
||||
export class ModuleLiveSyncMain extends AbstractModule {
|
||||
async _onLiveSyncReady() {
|
||||
if (!(await this.core.services.appLifecycle.onLayoutReady())) return false;
|
||||
@@ -44,13 +60,20 @@ export class ModuleLiveSyncMain extends AbstractModule {
|
||||
}
|
||||
}
|
||||
// Ordinary start-up may continue when individual files could not be
|
||||
// processed. Explicit Fetch and Rebuild flows retain the strict default.
|
||||
// processed. Explicit Fetch and Rebuild flows remain strict.
|
||||
const { ignoreSuspending = false, continueOnFileFailure = true } = this.core.startupDatabaseOptions;
|
||||
// Only ordinary Obsidian start-up honours the opt-out. Keep other hosts
|
||||
// and explicit operation modes strict.
|
||||
const strictContentCheck =
|
||||
!isObsidianPlatform(this.services.API.getPlatform()) ||
|
||||
this.settings.strictContentCheckOnStartup === true ||
|
||||
this.core.startupDatabaseOptions.continueOnFileFailure === false;
|
||||
const initialisationResult = await this.services.databaseEvents.initialiseDatabase(
|
||||
false,
|
||||
false,
|
||||
ignoreSuspending,
|
||||
continueOnFileFailure
|
||||
continueOnFileFailure,
|
||||
{ strictContentCheck }
|
||||
);
|
||||
if (initialisationResult === VaultScanResults.FAILED) {
|
||||
this._log($msg("Ui.Common.LocalDatabaseInitialisationFailed"), LOG_LEVEL_NOTICE);
|
||||
|
||||
@@ -90,9 +90,11 @@ describe("ModuleLiveSyncMain", () => {
|
||||
},
|
||||
},
|
||||
services: {
|
||||
API: { getPlatform: () => "desktop" },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
settings: {
|
||||
strictContentCheckOnStartup: false,
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
},
|
||||
@@ -102,7 +104,7 @@ describe("ModuleLiveSyncMain", () => {
|
||||
const result = await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, true);
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, true, { strictContentCheck: false });
|
||||
expect(log).toHaveBeenCalledWith("Ui.Common.LocalDatabaseInitialisationFailed", LOG_LEVEL_NOTICE);
|
||||
});
|
||||
|
||||
@@ -120,6 +122,7 @@ describe("ModuleLiveSyncMain", () => {
|
||||
services: { appLifecycle },
|
||||
},
|
||||
services: {
|
||||
API: { getPlatform: () => "desktop" },
|
||||
appLifecycle,
|
||||
control: { applySettings: vi.fn(async () => undefined) },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
@@ -152,6 +155,7 @@ describe("ModuleLiveSyncMain", () => {
|
||||
},
|
||||
},
|
||||
services: {
|
||||
API: { getPlatform: () => "desktop" },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
settings: {
|
||||
@@ -163,6 +167,96 @@ describe("ModuleLiveSyncMain", () => {
|
||||
|
||||
await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
||||
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, true, false);
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, true, false, { strictContentCheck: true });
|
||||
});
|
||||
|
||||
it("passes the strict content check setting for ordinary Obsidian start-up", async () => {
|
||||
const initialiseDatabase = vi.fn(async () => false);
|
||||
const host = {
|
||||
core: {
|
||||
startupDatabaseOptions: {},
|
||||
services: {
|
||||
appLifecycle: {
|
||||
onLayoutReady: vi.fn(async () => true),
|
||||
},
|
||||
},
|
||||
},
|
||||
services: {
|
||||
API: { getPlatform: () => "desktop" },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
settings: {
|
||||
strictContentCheckOnStartup: true,
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
},
|
||||
_log: vi.fn(),
|
||||
};
|
||||
|
||||
await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
||||
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, true, { strictContentCheck: true });
|
||||
});
|
||||
|
||||
it.each(["server", "browser"])(
|
||||
"keeps the strict content check on non-Obsidian host start-up (%s)",
|
||||
async (platform) => {
|
||||
const initialiseDatabase = vi.fn(async () => false);
|
||||
const host = {
|
||||
core: {
|
||||
startupDatabaseOptions: { ignoreSuspending: false, continueOnFileFailure: true },
|
||||
services: {
|
||||
appLifecycle: {
|
||||
onLayoutReady: vi.fn(async () => true),
|
||||
},
|
||||
},
|
||||
},
|
||||
services: {
|
||||
API: { getPlatform: () => platform },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
settings: {
|
||||
strictContentCheckOnStartup: false,
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
},
|
||||
_log: vi.fn(),
|
||||
};
|
||||
|
||||
await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
||||
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, true, { strictContentCheck: true });
|
||||
}
|
||||
);
|
||||
|
||||
it("keeps explicit recovery startup options strict when the setting is off", async () => {
|
||||
const initialiseDatabase = vi.fn(async () => false);
|
||||
const host = {
|
||||
core: {
|
||||
startupDatabaseOptions: {
|
||||
ignoreSuspending: false,
|
||||
continueOnFileFailure: false,
|
||||
},
|
||||
services: {
|
||||
appLifecycle: {
|
||||
onLayoutReady: vi.fn(async () => true),
|
||||
},
|
||||
},
|
||||
},
|
||||
services: {
|
||||
API: { getPlatform: () => "desktop" },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
settings: {
|
||||
strictContentCheckOnStartup: false,
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
},
|
||||
_log: vi.fn(),
|
||||
};
|
||||
|
||||
await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
||||
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, false, { strictContentCheck: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,396 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import type { GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
|
||||
import { $msg as translate } from "@/common/translation";
|
||||
import type {
|
||||
JoinedDeviceRow,
|
||||
JoinedDevicesDialogOptions,
|
||||
JoinedDevicesView,
|
||||
} from "@/serviceFeatures/joinedDevicesDialogTypes";
|
||||
|
||||
const { setResult, getInitialData }: GuestDialogProps<"close", JoinedDevicesDialogOptions> = $props();
|
||||
let options = $state<JoinedDevicesDialogOptions>();
|
||||
let view = $state<JoinedDevicesView>();
|
||||
let isRefreshing = $state(false);
|
||||
let confirmingId = $state<string>();
|
||||
let forgettingId = $state<string>();
|
||||
let statusMessage = $state("");
|
||||
let errorMessage = $state("");
|
||||
let disposed = false;
|
||||
const busy = $derived(isRefreshing || forgettingId !== undefined);
|
||||
const title = translate("Joined devices");
|
||||
|
||||
onMount(() => {
|
||||
options = getInitialData?.();
|
||||
if (!options) {
|
||||
errorMessage = translate(
|
||||
"The device information could not be loaded. Close this dialogue and open Joined devices again if the remote configuration has changed."
|
||||
);
|
||||
return;
|
||||
}
|
||||
void refreshView();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
disposed = true;
|
||||
options?.cancel();
|
||||
});
|
||||
|
||||
async function refreshView(afterForget = false) {
|
||||
const dialogOptions = options;
|
||||
if (!dialogOptions || disposed || isRefreshing || (forgettingId !== undefined && !afterForget)) return;
|
||||
|
||||
isRefreshing = true;
|
||||
errorMessage = "";
|
||||
if (!afterForget) {
|
||||
statusMessage = "";
|
||||
confirmingId = undefined;
|
||||
}
|
||||
try {
|
||||
const refreshedView = await dialogOptions.refresh();
|
||||
if (disposed) return;
|
||||
view = refreshedView;
|
||||
} catch {
|
||||
if (!disposed) {
|
||||
errorMessage = translate(
|
||||
"The device information could not be loaded. Close this dialogue and open Joined devices again if the remote configuration has changed."
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!disposed) isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleConfirmation(device: JoinedDeviceRow) {
|
||||
if (busy || device.self) return;
|
||||
confirmingId = confirmingId === device.id ? undefined : device.id;
|
||||
errorMessage = "";
|
||||
statusMessage = "";
|
||||
}
|
||||
|
||||
async function forgetDevice(device: JoinedDeviceRow) {
|
||||
const dialogOptions = options;
|
||||
if (!dialogOptions || disposed || busy || device.self || confirmingId !== device.id) return;
|
||||
|
||||
forgettingId = device.id;
|
||||
confirmingId = undefined;
|
||||
errorMessage = "";
|
||||
statusMessage = "";
|
||||
try {
|
||||
const result = await dialogOptions.forget(device.id);
|
||||
if (disposed) return;
|
||||
|
||||
if (result === "forgotten") {
|
||||
statusMessage = translate("The device record was forgotten.");
|
||||
await refreshView(true);
|
||||
} else if (result === "not-found") {
|
||||
statusMessage = translate("The device record was already absent.");
|
||||
await refreshView(true);
|
||||
} else if (result === "conflict") {
|
||||
errorMessage = translate(
|
||||
"The device list changed while you were trying to forget this device. Refresh the list before trying again."
|
||||
);
|
||||
} else {
|
||||
errorMessage = translate(
|
||||
"This remote does not support conditional updates, so the device record could not be removed."
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
if (!disposed) {
|
||||
errorMessage = translate(
|
||||
"Joined devices could not be updated. Refresh the list and try again. If the remote configuration has changed, close this dialogue and open Joined devices again."
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!disposed) forgettingId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatLastConnected(timestamp: number | undefined) {
|
||||
if (timestamp === undefined || !Number.isFinite(timestamp) || timestamp <= 0) return translate("Not recorded");
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return translate("Not recorded");
|
||||
return date.toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<DialogHeader {title} />
|
||||
<div class="joined-devices-dialog">
|
||||
<p>
|
||||
{translate("This list shows device information recorded by the remote. It does not show whether a device is online now.")}
|
||||
</p>
|
||||
|
||||
<InfoNote
|
||||
message="Synchronisation between different plug-in versions is generally safe unless the connection is explicitly blocked. However, it may be less efficient or use more storage, and P2P synchronisation may be unavailable."
|
||||
/>
|
||||
|
||||
{#if view}
|
||||
{#if !view.milestonePresent}
|
||||
<p class="state-message">{translate("This remote does not currently have a record of joined devices.")}</p>
|
||||
{/if}
|
||||
{#if view.cleaned}
|
||||
<p class="state-message">
|
||||
{translate(
|
||||
"This remote has been cleaned. Use the existing recovery process to join devices again as needed."
|
||||
)}
|
||||
</p>
|
||||
{/if}
|
||||
{#if view.locked}
|
||||
<p class="state-message">
|
||||
{translate(
|
||||
"This remote is locked. Use the existing recovery process for devices that need to join again."
|
||||
)}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if errorMessage}
|
||||
<p class="error-message" role="alert">{errorMessage}</p>
|
||||
{/if}
|
||||
{#if statusMessage}
|
||||
<p class="status-message" role="status">{statusMessage}</p>
|
||||
{/if}
|
||||
{#if isRefreshing && !view}
|
||||
<p role="status">{translate("Loading joined devices…")}</p>
|
||||
{/if}
|
||||
|
||||
{#if view}
|
||||
{#if view.devices.length > 0}
|
||||
<ul class="device-list">
|
||||
{#each view.devices as device (device.id)}
|
||||
<li class="device-card">
|
||||
<div class="device-heading">
|
||||
<div class="device-identity">
|
||||
{#if device.self}
|
||||
<span class="device-badge">{translate("This device")}</span>
|
||||
{/if}
|
||||
<code class="device-id">{device.id}</code>
|
||||
</div>
|
||||
<div class="device-badges">
|
||||
<span class="device-badge">
|
||||
{device.accepted ? translate("Accepted") : translate("Not accepted")}
|
||||
</span>
|
||||
{#if device.lockedOut}
|
||||
<span class="device-badge device-badge-warning">{translate("Locked out")}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if device.self && device.lockedOut}
|
||||
<p class="state-message">
|
||||
{translate(
|
||||
"This device has been locked out. Use the existing recovery process to join again."
|
||||
)}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<dl class="device-details">
|
||||
<div>
|
||||
<dt>{translate("Platform")}</dt>
|
||||
<dd>{device.platform || translate("Unknown platform")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{translate("Plug-in version")}</dt>
|
||||
<dd>{device.pluginVersion || translate("Not recorded")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{translate("App version")}</dt>
|
||||
<dd>{device.appVersion || translate("Not recorded")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{translate("Last recorded")}</dt>
|
||||
<dd>{formatLastConnected(device.lastConnected)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{#if !device.self}
|
||||
{#if confirmingId === device.id}
|
||||
<div class="forget-confirmation">
|
||||
<p><strong>{translate("Forget this device record?")}</strong></p>
|
||||
<p>
|
||||
{translate("This removes only this device's record from the remote.")}
|
||||
</p>
|
||||
<p>
|
||||
{translate(
|
||||
"Forgetting the record does not delete Vault files or revoke credentials."
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{view.locked
|
||||
? translate(
|
||||
"This remote is locked. If the device returns, use the existing recovery process to join it again."
|
||||
)
|
||||
: translate("This remote is unlocked. A device that returns can register again.")}
|
||||
</p>
|
||||
<div class="device-actions">
|
||||
<button disabled={busy} onclick={() => toggleConfirmation(device)}>
|
||||
{translate("Cancel")}
|
||||
</button>
|
||||
<button
|
||||
class="mod-warning"
|
||||
disabled={busy}
|
||||
onclick={() => void forgetDevice(device)}
|
||||
>
|
||||
{translate("Forget device")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="device-actions">
|
||||
<button disabled={busy} onclick={() => toggleConfirmation(device)}>
|
||||
{forgettingId === device.id
|
||||
? translate("Forgetting…")
|
||||
: translate("Forget device")}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if !isRefreshing}
|
||||
<p>{translate("No joined devices are recorded on this remote.")}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="dialog-controls">
|
||||
<button disabled={busy} onclick={() => void refreshView()}>{translate("Refresh")}</button>
|
||||
<button onclick={() => setResult("close")}>{translate("Close")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.joined-devices-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-4-2);
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.state-message,
|
||||
.forget-confirmation {
|
||||
border-left: 3px solid var(--interactive-accent);
|
||||
padding: var(--size-4-2);
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
.device-list {
|
||||
display: grid;
|
||||
gap: var(--size-4-2);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-4-2);
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-m);
|
||||
padding: var(--size-4-3);
|
||||
}
|
||||
|
||||
.device-heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--size-4-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-identity,
|
||||
.device-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--size-4-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-id {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.device-badge {
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--background-secondary);
|
||||
padding: 0 var(--size-4-2);
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
|
||||
.device-badge-warning {
|
||||
color: var(--text-warning);
|
||||
}
|
||||
|
||||
.device-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
|
||||
gap: var(--size-4-2) var(--size-4-4);
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-details > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.forget-confirmation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-4-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-actions,
|
||||
.dialog-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: var(--size-4-2);
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.device-card {
|
||||
padding: var(--size-4-2);
|
||||
}
|
||||
|
||||
.device-details {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,6 @@
|
||||
import { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { createWebStorageValueStore } from "@vrtmrz/value-store/web-storage";
|
||||
import { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
|
||||
import type { ServiceInstances } from "@vrtmrz/livesync-commonlib/compat/services/ServiceHub";
|
||||
import type ObsidianLiveSyncPlugin from "@/main";
|
||||
@@ -11,7 +13,6 @@ import {
|
||||
ObsidianTweakValueService,
|
||||
ObsidianTestService,
|
||||
ObsidianDatabaseEventService,
|
||||
ObsidianConfigService,
|
||||
ObsidianKeyValueDBService,
|
||||
ObsidianControlService,
|
||||
} from "./ObsidianServices";
|
||||
@@ -46,6 +47,7 @@ export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceCont
|
||||
const setting = new ObsidianSettingService(context, {
|
||||
APIService: API,
|
||||
onDisplayLanguageChanged: setLang,
|
||||
deviceValues: createWebStorageValueStore(compatGlobal.localStorage, { prefix: "" }),
|
||||
});
|
||||
const appLifecycle = new ObsidianAppLifecycleService(context, {
|
||||
settingService: setting,
|
||||
@@ -85,10 +87,7 @@ export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceCont
|
||||
databaseEvents: databaseEvents,
|
||||
vault: vault,
|
||||
});
|
||||
const config = new ObsidianConfigService(context, {
|
||||
settingService: setting,
|
||||
APIService: API,
|
||||
});
|
||||
const config = setting.config;
|
||||
const replicator = new ObsidianReplicatorService(context, {
|
||||
settingService: setting,
|
||||
appLifecycleService: appLifecycle,
|
||||
|
||||
@@ -6,7 +6,6 @@ import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/
|
||||
import { InjectableReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicatorService";
|
||||
import { InjectableTestService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTestService";
|
||||
import { InjectableTweakValueService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTweakValueService";
|
||||
import { ConfigServiceBrowserCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/ConfigServiceBrowserCompat";
|
||||
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
|
||||
import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
|
||||
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
||||
@@ -58,10 +57,7 @@ export class ObsidianReplicatorService extends InjectableReplicatorService<Obsid
|
||||
super(context, withSleepPreference(dependencies));
|
||||
}
|
||||
|
||||
async runBoundedLocalApplicationActivity<T>(
|
||||
task: () => T | PromiseLike<T>,
|
||||
options?: ActivityOptions
|
||||
): Promise<T> {
|
||||
async runBoundedLocalApplicationActivity<T>(task: () => T | PromiseLike<T>, options?: ActivityOptions): Promise<T> {
|
||||
this.boundedLocalApplicationActivityCount.value++;
|
||||
try {
|
||||
return this.dependencies.activityRunner
|
||||
@@ -84,7 +80,6 @@ export class ObsidianConflictService extends InjectableConflictService<ObsidianS
|
||||
export class ObsidianTweakValueService extends InjectableTweakValueService<ObsidianServiceContext> {}
|
||||
// InjectableTestService
|
||||
export class ObsidianTestService extends InjectableTestService<ObsidianServiceContext> {}
|
||||
export class ObsidianConfigService extends ConfigServiceBrowserCompat<ObsidianServiceContext> {}
|
||||
|
||||
export class ObsidianKeyValueDBService extends KeyValueDBService<ObsidianServiceContext> {}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
EVENT_REQUEST_RELOAD_SETTING_TAB,
|
||||
@@ -27,19 +26,6 @@ export class ObsidianSettingService<T extends ObsidianServiceContext> extends Se
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
}
|
||||
protected setItem(key: string, value: string) {
|
||||
// TODO: Implement nativeLocalStorage.
|
||||
return compatGlobal.localStorage.setItem(key, value);
|
||||
}
|
||||
protected getItem(key: string): string {
|
||||
// TODO: Implement nativeLocalStorage.
|
||||
return compatGlobal.localStorage.getItem(key) ?? "";
|
||||
}
|
||||
protected deleteItem(key: string): void {
|
||||
// TODO: Implement nativeLocalStorage.
|
||||
compatGlobal.localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
protected override async saveData(data: ObsidianLiveSyncSettings): Promise<void> {
|
||||
return await this.context.liveSyncPlugin.saveData(data);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { IAPIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
|
||||
import { normaliseObsidianSettingsData } from "./ObsidianSettingService.ts";
|
||||
import { ObsidianSettingService } from "./ObsidianSettingService.ts";
|
||||
|
||||
function createSettingService(
|
||||
vaultName: string,
|
||||
deviceValues: MemoryValueStore
|
||||
): ObsidianSettingService<ObsidianServiceContext> {
|
||||
const context = createServiceContext() as unknown as ObsidianServiceContext;
|
||||
const APIService = {
|
||||
addLog: () => undefined,
|
||||
getSystemVaultName: () => vaultName,
|
||||
} as unknown as IAPIService;
|
||||
return new ObsidianSettingService(context, { APIService, deviceValues });
|
||||
}
|
||||
|
||||
describe("normaliseObsidianSettingsData", () => {
|
||||
it("maps Obsidian's missing data value to Commonlib's new-Vault input", () => {
|
||||
@@ -12,4 +29,26 @@ describe("normaliseObsidianSettingsData", () => {
|
||||
|
||||
expect(normaliseObsidianSettingsData(settings)).toBe(settings);
|
||||
});
|
||||
|
||||
it("keeps injected device values independent from each Vault-scoped configuration store", async () => {
|
||||
const firstValues = new MemoryValueStore();
|
||||
const secondValues = new MemoryValueStore();
|
||||
const firstSetting = createSettingService("first-vault", firstValues);
|
||||
const secondSetting = createSettingService("second-vault", secondValues);
|
||||
|
||||
await firstSetting.deviceValues.setValue("shared-key", "device value");
|
||||
await firstSetting.config.setValue("shared-key", "first config");
|
||||
await secondSetting.config.setValue("shared-key", "second config");
|
||||
|
||||
expect(firstSetting.deviceValues).toBe(firstValues);
|
||||
expect(secondSetting.deviceValues).toBe(secondValues);
|
||||
expect(firstSetting.deviceValues).not.toBe(secondSetting.deviceValues);
|
||||
expect(firstSetting.config).not.toBe(firstValues);
|
||||
expect(firstSetting.config).not.toBe(secondSetting.config);
|
||||
await expect(firstSetting.deviceValues.getValue("shared-key")).resolves.toBe("device value");
|
||||
await expect(firstSetting.config.getValue("shared-key")).resolves.toBe("first config");
|
||||
await expect(secondSetting.config.getValue("shared-key")).resolves.toBe("second config");
|
||||
await expect(firstValues.getValue("first-vault-shared-key")).resolves.toBe("first config");
|
||||
await expect(secondValues.getValue("second-vault-shared-key")).resolves.toBe("second config");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService";
|
||||
import type { IConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
|
||||
import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
|
||||
import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService";
|
||||
@@ -8,7 +8,7 @@ import DialogToCopy from "@/modules/services/LiveSyncUI/dialogues/DialogueToCopy
|
||||
import type { IAPIService, IControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
export type ObsidianUIServiceDependencies<T extends ObsidianServiceContext = ObsidianServiceContext> = {
|
||||
appLifecycle: AppLifecycleService<T>;
|
||||
config: ConfigService<T>;
|
||||
config: IConfigService;
|
||||
replicator: ReplicatorService<T>;
|
||||
APIService: IAPIService;
|
||||
control: IControlService;
|
||||
|
||||
@@ -44,17 +44,17 @@ export class CompatibilityReviewController {
|
||||
return this._initialised;
|
||||
}
|
||||
|
||||
private readAcknowledgedVersion(): string | null {
|
||||
private async readAcknowledgedVersion(): Promise<string | null> {
|
||||
const setting = this.core.services.setting;
|
||||
const currentMarker = setting.getSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY);
|
||||
const currentMarker = await this.core.services.config.getValue(DATABASE_COMPATIBILITY_VERSION_KEY, "");
|
||||
if (currentMarker) return currentMarker;
|
||||
|
||||
const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName());
|
||||
const legacyMarker = setting.getDeviceLocalConfig(legacyKey);
|
||||
const legacyMarker = await setting.deviceValues.getValue(legacyKey, "");
|
||||
if (!legacyMarker) return null;
|
||||
|
||||
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, legacyMarker);
|
||||
setting.deleteDeviceLocalConfig(legacyKey);
|
||||
await this.core.services.config.setValue(DATABASE_COMPATIBILITY_VERSION_KEY, legacyMarker);
|
||||
await setting.deviceValues.delete(legacyKey);
|
||||
return legacyMarker;
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export class CompatibilityReviewController {
|
||||
return true;
|
||||
}
|
||||
|
||||
const acknowledgedVersion = this.readAcknowledgedVersion();
|
||||
const acknowledgedVersion = await this.readAcknowledgedVersion();
|
||||
const evaluation = evaluateCompatibilityPause({
|
||||
acknowledgedVersion,
|
||||
currentVersion: this.currentVersion,
|
||||
@@ -85,7 +85,7 @@ export class CompatibilityReviewController {
|
||||
|
||||
this.pause = evaluation.pause;
|
||||
if (evaluation.initialiseAcknowledgedVersion) {
|
||||
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
||||
await this.core.services.config.setValue(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
||||
this._initialised = true;
|
||||
return true;
|
||||
}
|
||||
@@ -115,9 +115,9 @@ export class CompatibilityReviewController {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
||||
await this.core.services.config.setValue(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
||||
const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName());
|
||||
setting.deleteDeviceLocalConfig(legacyKey);
|
||||
await setting.deviceValues.delete(legacyKey);
|
||||
this.pause = undefined;
|
||||
this.ui.clearReminder();
|
||||
await this.core.services.control.applySettings();
|
||||
|
||||
@@ -33,13 +33,32 @@ function createFixture(
|
||||
} = {}
|
||||
) {
|
||||
const local = new Map<string, string>();
|
||||
const deviceValues = new Map<string, string>();
|
||||
if (options.marker !== undefined && options.marker !== null) {
|
||||
local.set(DATABASE_COMPATIBILITY_VERSION_KEY, options.marker);
|
||||
}
|
||||
const legacyKey = legacyDatabaseCompatibilityVersionKey("Test Vault");
|
||||
if (options.legacyMarker !== undefined && options.legacyMarker !== null) {
|
||||
local.set(legacyKey, options.legacyMarker);
|
||||
deviceValues.set(legacyKey, options.legacyMarker);
|
||||
}
|
||||
const config = {
|
||||
getValue: vi.fn(async (key: string, defaultValue = "") => local.get(key) ?? defaultValue),
|
||||
setValue: vi.fn(async (key: string, value: string) => {
|
||||
local.set(key, value);
|
||||
}),
|
||||
delete: vi.fn(async (key: string) => {
|
||||
local.delete(key);
|
||||
}),
|
||||
};
|
||||
const deviceValueStore = {
|
||||
getValue: vi.fn(async (key: string, defaultValue = "") => deviceValues.get(key) ?? defaultValue),
|
||||
setValue: vi.fn(async (key: string, value: string) => {
|
||||
deviceValues.set(key, value);
|
||||
}),
|
||||
delete: vi.fn(async (key: string) => {
|
||||
deviceValues.delete(key);
|
||||
}),
|
||||
};
|
||||
const settings = {
|
||||
versionUpFlash: options.versionUpFlash ?? "",
|
||||
isConfigured: options.isConfigured ?? true,
|
||||
@@ -49,15 +68,13 @@ function createFixture(
|
||||
const setting = {
|
||||
currentSettings: () => settings,
|
||||
getSettingsMigrationState: () => migrationState(options.migration),
|
||||
getSmallConfig: (key: string) => local.get(key) ?? "",
|
||||
setSmallConfig: (key: string, value: string) => local.set(key, value),
|
||||
getDeviceLocalConfig: (key: string) => local.get(key) ?? null,
|
||||
deleteDeviceLocalConfig: (key: string) => local.delete(key),
|
||||
deviceValues: deviceValueStore,
|
||||
saveSettingData,
|
||||
};
|
||||
const core = {
|
||||
services: {
|
||||
setting,
|
||||
config,
|
||||
vault: { getVaultName: () => "Test Vault" },
|
||||
control: { applySettings },
|
||||
},
|
||||
@@ -69,7 +86,18 @@ function createFixture(
|
||||
clearReminder: vi.fn(),
|
||||
};
|
||||
const controller = new CompatibilityReviewController(core, ui, 12);
|
||||
return { controller, ui, local, legacyKey, settings, saveSettingData, applySettings };
|
||||
return {
|
||||
controller,
|
||||
ui,
|
||||
local,
|
||||
deviceValues,
|
||||
config,
|
||||
deviceValueStore,
|
||||
legacyKey,
|
||||
settings,
|
||||
saveSettingData,
|
||||
applySettings,
|
||||
};
|
||||
}
|
||||
|
||||
describe("compatibility review controller", () => {
|
||||
@@ -196,10 +224,96 @@ describe("compatibility review controller", () => {
|
||||
await fixture.controller.initialise();
|
||||
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||
expect(fixture.local.has(fixture.legacyKey)).toBe(false);
|
||||
expect(fixture.deviceValues.has(fixture.legacyKey)).toBe(false);
|
||||
expect(fixture.controller.pendingPause).toBeDefined();
|
||||
});
|
||||
|
||||
it("saves the scoped marker before deleting the legacy device value", async () => {
|
||||
const fixture = createFixture({ legacyMarker: "11" });
|
||||
let finishSave!: () => void;
|
||||
const saving = new Promise<void>((resolve) => {
|
||||
finishSave = resolve;
|
||||
});
|
||||
fixture.config.setValue.mockImplementation(async (key, value) => {
|
||||
await saving;
|
||||
fixture.local.set(key, value);
|
||||
});
|
||||
|
||||
const initialising = fixture.controller.initialise();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fixture.deviceValues.get(fixture.legacyKey)).toBe("11");
|
||||
expect(fixture.deviceValueStore.delete).not.toHaveBeenCalled();
|
||||
expect(fixture.controller.initialised).toBe(false);
|
||||
|
||||
finishSave();
|
||||
await initialising;
|
||||
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||
expect(fixture.deviceValues.has(fixture.legacyKey)).toBe(false);
|
||||
expect(fixture.controller.initialised).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat a failed marker read as an absent value", async () => {
|
||||
const fixture = createFixture({ legacyMarker: "11" });
|
||||
fixture.config.getValue.mockRejectedValueOnce(new Error("marker read failed"));
|
||||
|
||||
await expect(fixture.controller.initialise()).rejects.toThrow("marker read failed");
|
||||
|
||||
expect(fixture.deviceValueStore.getValue).not.toHaveBeenCalled();
|
||||
expect(fixture.deviceValues.get(fixture.legacyKey)).toBe("11");
|
||||
expect(fixture.controller.initialised).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the compatibility pause blocked until its async marker write succeeds", async () => {
|
||||
const fixture = createFixture({ marker: "11" });
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||
await fixture.controller.initialise();
|
||||
let finishSave!: () => void;
|
||||
const saving = new Promise<void>((resolve) => {
|
||||
finishSave = resolve;
|
||||
});
|
||||
fixture.config.setValue.mockImplementation(async (key, value) => {
|
||||
await saving;
|
||||
fixture.local.set(key, value);
|
||||
});
|
||||
|
||||
const reviewing = fixture.controller.openReview();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fixture.controller.pendingPause).toBeDefined();
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||
expect(fixture.applySettings).not.toHaveBeenCalled();
|
||||
|
||||
finishSave();
|
||||
await reviewing;
|
||||
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
|
||||
expect(fixture.controller.pendingPause).toBeUndefined();
|
||||
expect(fixture.applySettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps a storage-failed acknowledgement blocked and retryable", async () => {
|
||||
const fixture = createFixture({ marker: "11" });
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||
await fixture.controller.initialise();
|
||||
fixture.config.setValue.mockRejectedValueOnce(new Error("marker write failed"));
|
||||
|
||||
await expect(fixture.controller.openReview()).rejects.toThrow("marker write failed");
|
||||
|
||||
expect(fixture.controller.pendingPause).toBeDefined();
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||
expect(fixture.applySettings).not.toHaveBeenCalled();
|
||||
|
||||
await fixture.controller.openReview();
|
||||
|
||||
expect(fixture.controller.pendingPause).toBeUndefined();
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
|
||||
expect(fixture.applySettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("restores the runtime gate if persisting an acknowledgement fails", async () => {
|
||||
const fixture = createFixture({ marker: "11" });
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { JoinedDevice, JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import type { JoinedDevicesView } from "./joinedDevicesDialogTypes";
|
||||
|
||||
/** Compare recognised SemVer values; unknown development labels have no ordering. */
|
||||
export function comparePluginVersions(left: string, right: string): number | undefined {
|
||||
const parse = (value: string) => {
|
||||
const match =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(
|
||||
value
|
||||
);
|
||||
if (!match) return undefined;
|
||||
const prerelease = match[4]?.split(".");
|
||||
if (prerelease?.some((part) => /^0\d+$/.test(part))) return undefined;
|
||||
return { core: match.slice(1, 4).map(BigInt), prerelease };
|
||||
};
|
||||
const a = parse(left);
|
||||
const b = parse(right);
|
||||
if (!a || !b) return undefined;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a.core[i] !== b.core[i]) return a.core[i] > b.core[i] ? 1 : -1;
|
||||
}
|
||||
if (!a.prerelease || !b.prerelease) return a.prerelease ? -1 : b.prerelease ? 1 : 0;
|
||||
for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i++) {
|
||||
const x = a.prerelease[i];
|
||||
const y = b.prerelease[i];
|
||||
if (x === undefined) return -1;
|
||||
if (y === undefined) return 1;
|
||||
if (x === y) continue;
|
||||
const xn = /^\d+$/.test(x);
|
||||
const yn = /^\d+$/.test(y);
|
||||
if (xn && yn) return BigInt(x) > BigInt(y) ? 1 : -1;
|
||||
if (xn !== yn) return xn ? -1 : 1;
|
||||
return x > y ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function joinedDevicesView(
|
||||
snapshot: JoinedDevicesSnapshot,
|
||||
self: Omit<JoinedDevice, "accepted">
|
||||
): JoinedDevicesView {
|
||||
const ownRecord = snapshot.devices.find((device) => device.id === self.id);
|
||||
const ownDevice: JoinedDevice = { ...ownRecord, ...self, accepted: ownRecord?.accepted ?? false };
|
||||
return {
|
||||
...snapshot,
|
||||
devices: [ownDevice, ...snapshot.devices.filter((device) => device.id !== self.id)].map((device) => ({
|
||||
...device,
|
||||
self: device.id === self.id,
|
||||
lockedOut: snapshot.locked && !device.accepted,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function differentDeviceVersions(snapshot: JoinedDevicesSnapshot, ownId: string, ownVersion: string) {
|
||||
const newer = new Set<string>();
|
||||
const older = new Set<string>();
|
||||
for (const device of snapshot.devices) {
|
||||
if (device.id === ownId || !device.pluginVersion) continue;
|
||||
const comparison = comparePluginVersions(device.pluginVersion, ownVersion);
|
||||
if (comparison !== undefined && comparison > 0) newer.add(device.pluginVersion);
|
||||
if (comparison !== undefined && comparison < 0) older.add(device.pluginVersion);
|
||||
}
|
||||
return { newer: [...newer].sort(), older: [...older].sort() };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { comparePluginVersions, differentDeviceVersions, joinedDevicesView } from "./joinedDevices";
|
||||
import type { JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
const empty: JoinedDevicesSnapshot = { milestonePresent: false, locked: false, cleaned: false, devices: [] };
|
||||
|
||||
describe("joined device presentation", () => {
|
||||
it("always shows this device first, including before its remote record exists", () => {
|
||||
const view = joinedDevicesView(
|
||||
{ ...empty, devices: [{ id: "another", accepted: true }] },
|
||||
{ id: "self", platform: "ios", pluginVersion: "1.0.39" }
|
||||
);
|
||||
expect(view.devices.map((device) => device.id)).toEqual(["self", "another"]);
|
||||
expect(view.devices[0]).toMatchObject({ self: true, accepted: false, lockedOut: false, platform: "ios" });
|
||||
});
|
||||
it("keeps the remote last-recorded date and acceptance while showing current local versions", () => {
|
||||
const view = joinedDevicesView(
|
||||
{
|
||||
...empty,
|
||||
milestonePresent: true,
|
||||
locked: true,
|
||||
devices: [
|
||||
{ id: "other", accepted: true },
|
||||
{ id: "self", accepted: false, lastConnected: 123, pluginVersion: "1.0.0" },
|
||||
],
|
||||
},
|
||||
{ id: "self", pluginVersion: "1.1.0" }
|
||||
);
|
||||
expect(view.devices[0]).toMatchObject({
|
||||
self: true,
|
||||
lockedOut: true,
|
||||
lastConnected: 123,
|
||||
pluginVersion: "1.1.0",
|
||||
});
|
||||
expect(view.devices[1].lockedOut).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("version advisories", () => {
|
||||
it.each([
|
||||
["1.10.0", "1.9.9", 1],
|
||||
["1.0.0", "1.0.0-rc.1", 1],
|
||||
["1.0.0-beta.2", "1.0.0-beta.10", -1],
|
||||
["1.0.0-1", "1.0.0-alpha", -1],
|
||||
["1.0.0-alpha", "1.0.0-alpha.1", -1],
|
||||
["1.0.0+first", "1.0.0+second", 0],
|
||||
["99999999999999999.0.0", "99999999999999998.0.0", 1],
|
||||
])("compares %s with %s", (a, b, result) => expect(comparePluginVersions(a, b)).toBe(result));
|
||||
it.each(["", "development", "1.0", "01.0.0", "1.0.0-01", "v1.0.0"])(
|
||||
"does not order unknown version %s",
|
||||
(version) => {
|
||||
expect(comparePluginVersions(version, "1.0.0")).toBeUndefined();
|
||||
expect(comparePluginVersions("1.0.0", version)).toBeUndefined();
|
||||
}
|
||||
);
|
||||
it("excludes this device and unknown versions, and deduplicates versions", () => {
|
||||
const snapshot = {
|
||||
...empty,
|
||||
devices: [
|
||||
{ id: "self", accepted: false, pluginVersion: "9.0.0" },
|
||||
{ id: "a", accepted: true, pluginVersion: "2.0.0" },
|
||||
{ id: "b", accepted: false, pluginVersion: "2.0.0" },
|
||||
{ id: "c", accepted: false, pluginVersion: "0.9.0" },
|
||||
{ id: "d", accepted: true, pluginVersion: "custom" },
|
||||
],
|
||||
};
|
||||
expect(differentDeviceVersions(snapshot, "self", "1.0.0")).toEqual({ newer: ["2.0.0"], older: ["0.9.0"] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { declareUICommand } from "@vrtmrz/livesync-commonlib/ui-commands";
|
||||
|
||||
export const OPEN_JOINED_DEVICES = declareUICommand<void, "closed" | "already-open" | "unavailable">(
|
||||
"livesync:joined-devices"
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ForgetJoinedDeviceResult, JoinedDevice } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
export interface JoinedDeviceRow extends JoinedDevice {
|
||||
readonly self: boolean;
|
||||
readonly lockedOut: boolean;
|
||||
}
|
||||
|
||||
export interface JoinedDevicesView {
|
||||
readonly milestonePresent: boolean;
|
||||
readonly locked: boolean;
|
||||
readonly cleaned: boolean;
|
||||
readonly devices: readonly JoinedDeviceRow[];
|
||||
}
|
||||
|
||||
export interface JoinedDevicesDialogOptions {
|
||||
refresh(): Promise<JoinedDevicesView>;
|
||||
forget(id: string): Promise<ForgetJoinedDeviceResult>;
|
||||
cancel(): void;
|
||||
}
|
||||
@@ -63,28 +63,27 @@ function buildSimpleFetchResult(stage1: string, stage2?: string) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function rememberSimpleFetchMode(host: NecessaryServices<"setting", never>, stage1: string, stage2?: string) {
|
||||
host.services.setting.setSmallConfig(SIMPLE_FETCH_MODE_KEY, JSON.stringify({ stage1, stage2 }));
|
||||
async function rememberSimpleFetchMode(host: NecessaryServices<"setting", never>, stage1: string, stage2?: string) {
|
||||
await host.services.setting.config.setValue(SIMPLE_FETCH_MODE_KEY, JSON.stringify({ stage1, stage2 }));
|
||||
}
|
||||
|
||||
function getRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
|
||||
const saved = host.services.setting.getSmallConfig(SIMPLE_FETCH_MODE_KEY);
|
||||
async function getRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
|
||||
const saved = await host.services.setting.config.getValue(SIMPLE_FETCH_MODE_KEY, "");
|
||||
if (!saved) return undefined;
|
||||
let remembered: ReturnType<typeof buildSimpleFetchResult> = undefined;
|
||||
try {
|
||||
const { stage1, stage2 } = JSON.parse(saved) as { stage1?: string; stage2?: string };
|
||||
if (stage1) {
|
||||
const remembered = buildSimpleFetchResult(stage1, stage2);
|
||||
if (remembered) return remembered;
|
||||
}
|
||||
if (stage1) remembered = buildSimpleFetchResult(stage1, stage2);
|
||||
} catch {
|
||||
// Clear below; the saved choice is optional and can be rebuilt by asking again.
|
||||
}
|
||||
host.services.setting.deleteSmallConfig(SIMPLE_FETCH_MODE_KEY);
|
||||
if (remembered) return remembered;
|
||||
await host.services.setting.config.delete(SIMPLE_FETCH_MODE_KEY);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function clearRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
|
||||
host.services.setting.deleteSmallConfig(SIMPLE_FETCH_MODE_KEY);
|
||||
async function clearRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
|
||||
await host.services.setting.config.delete(SIMPLE_FETCH_MODE_KEY);
|
||||
}
|
||||
|
||||
async function confirmTranslatedChoice(
|
||||
@@ -104,7 +103,7 @@ async function confirmTranslatedChoice(
|
||||
export async function askSimpleFetchMode(
|
||||
host: NecessaryServices<"UI" | "setting", never>
|
||||
): Promise<{ mode: string; options: Partial<FullScanOptions> } | "cancelled" | "aborted"> {
|
||||
const remembered = getRememberedSimpleFetchMode(host);
|
||||
const remembered = await getRememberedSimpleFetchMode(host);
|
||||
if (remembered) return remembered;
|
||||
|
||||
const msg = $msg(
|
||||
@@ -169,7 +168,7 @@ Firstly, how shall we handle the data retrieved from this remote source?
|
||||
if (stage2 === STAGE2_ABORT) {
|
||||
return "aborted";
|
||||
}
|
||||
rememberSimpleFetchMode(host, stage1, stage2);
|
||||
await rememberSimpleFetchMode(host, stage1, stage2);
|
||||
return buildSimpleFetchResult(stage1, stage2)!;
|
||||
}
|
||||
|
||||
@@ -198,7 +197,7 @@ Firstly, how shall we handle the data retrieved from this remote source?
|
||||
if (stage2 === STAGE2_ABORT) {
|
||||
return "aborted";
|
||||
}
|
||||
rememberSimpleFetchMode(host, stage1, stage2);
|
||||
await rememberSimpleFetchMode(host, stage1, stage2);
|
||||
return buildSimpleFetchResult(stage1, stage2)!;
|
||||
}
|
||||
|
||||
@@ -234,18 +233,18 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
|
||||
"Remediation mode is active, so the detailed fetch flow is used instead of Simple Fetch.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
await clearRememberedSimpleFetchMode(host);
|
||||
return undefined;
|
||||
}
|
||||
const result = await askSimpleFetchMode(host);
|
||||
if (result === "cancelled") {
|
||||
log("Fetch cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
await clearRememberedSimpleFetchMode(host);
|
||||
return await cancelScheduledInitialisation(host, cleanupFlag);
|
||||
}
|
||||
if (result === "aborted") {
|
||||
log("Fetch exited by user.", LOG_LEVEL_NOTICE);
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
await clearRememberedSimpleFetchMode(host);
|
||||
host.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
@@ -256,7 +255,7 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
|
||||
const settings = host.services.setting.currentSettings();
|
||||
if (!(await adjustSettingToRemoteIfNeeded(host, log, { preventFetchingConfig: false }, settings))) {
|
||||
log("Fetch initialisation cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
await clearRememberedSimpleFetchMode(host);
|
||||
return await cancelScheduledInitialisation(host, cleanupFlag);
|
||||
}
|
||||
|
||||
@@ -295,7 +294,7 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
|
||||
}
|
||||
await host.serviceModules.rebuilder.finishRebuild();
|
||||
await cleanupFlag();
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
await clearRememberedSimpleFetchMode(host);
|
||||
log("Simple fetch and scan operation completed.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -10,19 +10,26 @@ const KEEP_LOCAL = "Keep local files even if not on remote";
|
||||
function createHost(savedChoice?: string) {
|
||||
const smallConfig = new Map<string, string>();
|
||||
if (savedChoice) smallConfig.set("simple-fetch-mode", savedChoice);
|
||||
const config = {
|
||||
getValue: vi.fn(async (key: string, defaultValue = "") => smallConfig.get(key) ?? defaultValue),
|
||||
setValue: vi.fn(async (key: string, value: string) => {
|
||||
smallConfig.set(key, value);
|
||||
}),
|
||||
delete: vi.fn(async (key: string) => {
|
||||
smallConfig.delete(key);
|
||||
}),
|
||||
};
|
||||
const confirmWithMessage =
|
||||
vi.fn<(title: string, message: string, buttons: string[], defaultAction: string) => Promise<string | false>>();
|
||||
const host = {
|
||||
services: {
|
||||
UI: { confirm: { confirmWithMessage } },
|
||||
setting: {
|
||||
getSmallConfig: (key: string) => smallConfig.get(key) ?? "",
|
||||
setSmallConfig: (key: string, value: string) => smallConfig.set(key, value),
|
||||
deleteSmallConfig: (key: string) => smallConfig.delete(key),
|
||||
config,
|
||||
},
|
||||
},
|
||||
} as unknown as NecessaryServices<"UI" | "setting", never>;
|
||||
return { host, confirmWithMessage, smallConfig };
|
||||
return { host, confirmWithMessage, smallConfig, config };
|
||||
}
|
||||
|
||||
afterEach(() => setLang("def"));
|
||||
@@ -50,6 +57,75 @@ describe("Fast Setup translated choices", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for the saved choice before returning it to the fetch workflow", async () => {
|
||||
const { host, confirmWithMessage, smallConfig, config } = createHost();
|
||||
let markSaveStarted!: () => void;
|
||||
const saveStarted = new Promise<void>((resolve) => {
|
||||
markSaveStarted = resolve;
|
||||
});
|
||||
let finishSave!: () => void;
|
||||
const save = new Promise<void>((resolve) => {
|
||||
finishSave = resolve;
|
||||
});
|
||||
config.setValue.mockImplementation(async (key, value) => {
|
||||
markSaveStarted();
|
||||
await save;
|
||||
smallConfig.set(key, value);
|
||||
});
|
||||
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[1]);
|
||||
|
||||
const asking = askSimpleFetchMode(host);
|
||||
await saveStarted;
|
||||
|
||||
expect(smallConfig.has("simple-fetch-mode")).toBe(false);
|
||||
expect(confirmWithMessage).toHaveBeenCalledTimes(2);
|
||||
|
||||
finishSave();
|
||||
await expect(asking).resolves.toMatchObject({ mode: "remote-only" });
|
||||
expect(JSON.parse(smallConfig.get("simple-fetch-mode")!)).toEqual({
|
||||
stage1: REMOTE_WINS,
|
||||
stage2: KEEP_LOCAL,
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates a stored-choice read failure instead of treating it as missing", async () => {
|
||||
const { host, confirmWithMessage, config } = createHost();
|
||||
config.getValue.mockRejectedValueOnce(new Error("choice read failed"));
|
||||
|
||||
await expect(askSimpleFetchMode(host)).rejects.toThrow("choice read failed");
|
||||
|
||||
expect(confirmWithMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for an invalid optional choice to be deleted before asking again", async () => {
|
||||
const { host, confirmWithMessage, smallConfig, config } = createHost("not-json");
|
||||
let markDeleteStarted!: () => void;
|
||||
const deleteStarted = new Promise<void>((resolve) => {
|
||||
markDeleteStarted = resolve;
|
||||
});
|
||||
let finishDelete!: () => void;
|
||||
const deletion = new Promise<void>((resolve) => {
|
||||
finishDelete = resolve;
|
||||
});
|
||||
config.delete.mockImplementation(async (key) => {
|
||||
markDeleteStarted();
|
||||
await deletion;
|
||||
smallConfig.delete(key);
|
||||
});
|
||||
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[3]);
|
||||
|
||||
const asking = askSimpleFetchMode(host);
|
||||
await deleteStarted;
|
||||
|
||||
expect(confirmWithMessage).not.toHaveBeenCalled();
|
||||
expect(smallConfig.has("simple-fetch-mode")).toBe(true);
|
||||
|
||||
finishDelete();
|
||||
await expect(asking).resolves.toBe("cancelled");
|
||||
expect(smallConfig.has("simple-fetch-mode")).toBe(false);
|
||||
expect(confirmWithMessage).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("restores choices saved by the previous English implementation while displaying Spanish", async () => {
|
||||
const savedChoice = JSON.stringify({ stage1: REMOTE_WINS, stage2: KEEP_LOCAL });
|
||||
const { host, confirmWithMessage, smallConfig } = createHost(savedChoice);
|
||||
|
||||
@@ -107,13 +107,15 @@ const createSettingServiceMock = () => {
|
||||
}),
|
||||
suspendAllSync: vi.fn(() => Promise.resolve()),
|
||||
suspendExtraSync: vi.fn(() => Promise.resolve()),
|
||||
getSmallConfig: vi.fn((key: string) => smallConfig.get(key) ?? ""),
|
||||
setSmallConfig: vi.fn((key: string, value: string) => {
|
||||
config: {
|
||||
getValue: vi.fn(async (key: string, defaultValue = "") => smallConfig.get(key) ?? defaultValue),
|
||||
setValue: vi.fn(async (key: string, value: string) => {
|
||||
smallConfig.set(key, value);
|
||||
}),
|
||||
deleteSmallConfig: vi.fn((key: string) => {
|
||||
delete: vi.fn(async (key: string) => {
|
||||
smallConfig.delete(key);
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -785,7 +787,7 @@ describe("Red Flag Feature", () => {
|
||||
);
|
||||
|
||||
expect(host.mocks.ui.confirm.confirmWithMessage).not.toHaveBeenCalled();
|
||||
expect(host.mocks.setting.deleteSmallConfig).toHaveBeenCalledWith("simple-fetch-mode");
|
||||
expect(host.mocks.setting.config.delete).toHaveBeenCalledWith("simple-fetch-mode");
|
||||
expect(cleanupFlag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -830,7 +832,7 @@ describe("Red Flag Feature", () => {
|
||||
});
|
||||
expect(host.mocks.rebuilder.finishRebuild).not.toHaveBeenCalled();
|
||||
expect(cleanupFlag).not.toHaveBeenCalled();
|
||||
expect(host.mocks.setting.deleteSmallConfig).not.toHaveBeenCalledWith("simple-fetch-mode");
|
||||
expect(host.mocks.setting.config.delete).not.toHaveBeenCalledWith("simple-fetch-mode");
|
||||
});
|
||||
|
||||
it("re-suspends both reflection directions when finalisation fails after releasing them", async () => {
|
||||
@@ -892,7 +894,7 @@ describe("Red Flag Feature", () => {
|
||||
|
||||
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
|
||||
|
||||
expect(host.mocks.setting.deleteSmallConfig).toHaveBeenCalledWith("simple-fetch-mode");
|
||||
expect(host.mocks.setting.config.delete).toHaveBeenCalledWith("simple-fetch-mode");
|
||||
});
|
||||
|
||||
it("should return false and cleanup when quick flow is cancelled", async () => {
|
||||
|
||||
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user