mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-12 22:55:46 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c933674a0d | ||
|
|
eda78dee36 | ||
|
|
67c8424231 | ||
|
|
1735bc4d66 | ||
|
|
d11a92498d | ||
|
|
950623f5d6 | ||
|
|
bb72b6b317 | ||
|
|
769b7ff4b6 | ||
|
|
1bb50c1580 | ||
|
|
d17330f3c7 | ||
|
|
bd45924649 | ||
|
|
b1ad3e0653 | ||
|
|
6b94f0ce47 | ||
|
|
08b55b7677 | ||
|
|
21d904cfd6 | ||
|
|
00de35e5d4 | ||
|
|
f2976bc89a | ||
|
|
b65deede79 | ||
|
|
9203bdd40e | ||
|
|
fcd30d07be | ||
|
|
cfb75a05db | ||
|
|
5b19f4415d | ||
|
|
047429033f | ||
|
|
34ae802796 | ||
|
|
a3a09df3c8 | ||
|
|
4393a49cba |
@@ -8,7 +8,7 @@ assignees: ''
|
||||
---
|
||||
|
||||
Thank you for taking the time to report this issue!
|
||||
Before filling in this form, please read: [How to report an issue](../docs/to_issue_reporting.md).
|
||||
Before filling in this form, please read [How to report an issue](https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/to_issue_reporting.md).
|
||||
|
||||
Issues with sufficient information will be prioritised.
|
||||
|
||||
@@ -49,13 +49,14 @@ To get it: open the command palette → "Show debug info".
|
||||
</details>
|
||||
|
||||
### LiveSync version
|
||||
The hatch report (below) includes version information. If you cannot provide the report, please fill in the version here.
|
||||
The full LiveSync report below includes version information. If you cannot provide the report, please fill in the version here.
|
||||
|
||||
- Self-hosted LiveSync version: <!-- e.g. 0.23.0 — find it in Obsidian Settings → Community Plugins -->
|
||||
- Self-hosted LiveSync version: <!-- Find it in Obsidian Settings → Community plugins. -->
|
||||
|
||||
### Report and Logs from LiveSync
|
||||
Perform a `Generate full report for opening the issue with debug info` command and provide the generated report. This contains detailed information and recent 1000 log lines, which is very helpful for debugging. **PLEASE AMEND THE REPORT TO REMOVE ANY SENSITIVE INFORMATION BEFORE PASTING.**
|
||||
If too large to paste here, upload to [Gist](https://gist.github.com/) and share the link.
|
||||
Run `Generate full report for opening the issue with debug info` and provide the generated report. It contains detailed information and up to 1,000 recent log lines. Review the complete output, and remove credentials, private remote details, Vault names, file paths, file contents, and other private information before sharing it.
|
||||
|
||||
If the report is too large to paste here, upload the redacted report to [Gist](https://gist.github.com/) and share the link.
|
||||
|
||||
<details>
|
||||
<summary>Report and Logs (primary)</summary>
|
||||
|
||||
@@ -68,6 +68,13 @@ Each workflow establishes ordinary note synchronisation on the first device, gen
|
||||
> CouchDB can also be run on a Raspberry Pi (please be mindful of your server's security).
|
||||
|
||||
|
||||
### Third-party managed CouchDB hosting
|
||||
|
||||
> [!NOTE]
|
||||
> The following is a third-party hosting option proposed by Zenith Hosting. It is not an official Self-hosted LiveSync service, and it is neither endorsed nor recommended by this project.
|
||||
|
||||
If you would rather not set up and maintain a server yourself, Zenith Hosting offers a managed CouchDB server which this plug-in can be configured to connect to: [Zenith Hosting](https://zenith.hosting/host/obsidian-livesync). As with any hosted service, your data will reside on a server operated by a third party, so please consider whether that is acceptable for your vault before using it.
|
||||
|
||||
## Information in the Status Bar
|
||||
|
||||
Synchronisation status is shown in the status bar with the following icons.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Architectural Decision Record: Fast Fetch Transport Eligibility
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Fast Fetch accelerates Fast Setup (Simple Fetch) by reading bounded pages from
|
||||
CouchDB's continuous changes feed. It consumes each response incrementally,
|
||||
persists documents while the page is still arriving, and cancels the underlying
|
||||
request when the page completes or fails.
|
||||
|
||||
The CouchDB setting `useRequestAPI`, labelled 'Use Internal API', routes ordinary
|
||||
replication through Obsidian's `requestUrl` API to avoid browser CORS
|
||||
restrictions. This API exposes a completed response as text, JSON, or an
|
||||
`ArrayBuffer`; it does not expose the network response progressively or accept
|
||||
the Fetch API's `AbortSignal`. Wrapping its result in a `Response` does not
|
||||
restore those transport properties.
|
||||
|
||||
Custom headers can cause a browser preflight, and an authenticating proxy may
|
||||
reject that preflight before the requested header values are sent. Custom
|
||||
headers do not, however, make Fast Fetch intrinsically incompatible. A server
|
||||
with correctly configured CORS can accept the same headers through the ordinary
|
||||
Fetch API and retain streaming behaviour.
|
||||
|
||||
Ordinary PouchDB replication has a different response contract. Standard Fetch
|
||||
uses finite batches, while LiveSync uses long-poll responses whose change
|
||||
payload is bounded by the replication batch size. Both can process each response
|
||||
after it has completed and do not depend on progressively reading a
|
||||
document-bearing continuous feed.
|
||||
|
||||
## Decision
|
||||
|
||||
Fast Fetch requires a Fetch-compatible transport which exposes the response
|
||||
body progressively and honours request cancellation.
|
||||
|
||||
When `useRequestAPI` is enabled for a CouchDB remote, Fast Fetch falls back to
|
||||
Standard Fetch before entering the Fast Fetch activity or resetting the local
|
||||
database through the Fast Fetch path. The presence of custom headers alone does
|
||||
not disable Fast Fetch. Once Standard Fetch resets the local database, it
|
||||
invalidates any retained Fast Fetch checkpoint for that database.
|
||||
|
||||
Commonlib's Rebuilder owns this eligibility decision because it owns both Fast
|
||||
Fetch and the existing Standard Fetch fallback. The streaming implementation
|
||||
does not receive Obsidian's buffered request adapter, and LiveSync does not add
|
||||
proxy-specific or Cloudflare-specific policy.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Initial retrieval through Standard Fetch may be slower and issue more HTTP
|
||||
requests because PouchDB uses the configured batch size, document retrieval,
|
||||
and checkpoint operations. The decision does not assume that `requestUrl` is
|
||||
faster; its benefit here is compatibility with connections which browser CORS
|
||||
would otherwise reject.
|
||||
- LiveSync remains supported with `useRequestAPI`. Its HTTP adapter uses
|
||||
long-poll responses whose change payload is bounded by the replication batch
|
||||
size, rather than the document-bearing stream required by Fast Fetch.
|
||||
- A user whose server accepts the configured custom headers through correct
|
||||
CORS handling can leave `useRequestAPI` disabled and continue to use Fast
|
||||
Fetch.
|
||||
- The decision can be revisited if Obsidian provides a progressively readable,
|
||||
cancellable internal request API, or if a separately designed buffered
|
||||
transport establishes explicit payload bounds and equivalent cancellation
|
||||
semantics.
|
||||
|
||||
## Verification
|
||||
|
||||
Commonlib unit tests verify that `useRequestAPI` selects only the existing
|
||||
Standard Fetch activity, does not invoke Streaming Fetch, and invalidates any
|
||||
retained Fast Fetch checkpoint after the local database is reset. Existing
|
||||
tests continue to verify that custom headers are passed to Fast Fetch when
|
||||
`useRequestAPI` is disabled.
|
||||
|
||||
## References
|
||||
|
||||
- [Fast Fetch Persistence and Completion Semantics](2026_08_fast_fetch_persistence_and_completion.md)
|
||||
- [Apache CouchDB changes-feed API](https://docs.couchdb.org/en/stable/api/database/changes.html)
|
||||
@@ -141,6 +141,10 @@ This field stores an array of Chunk Document IDs.
|
||||
|
||||
\_id is generated based on the path of the Obsidian note.
|
||||
|
||||
The validation, quarantine, and explicit repair contract for normal-file
|
||||
Metadata whose stored path does not derive its actual ID is defined in
|
||||
[Normal-file Metadata Document ID Validation and Repair](design_docs/metadata_document_id_validation_and_repair.md).
|
||||
|
||||
- If the path starts with `_`, it is converted to `/_` for convenience.
|
||||
- If Case Sensitive is disabled, it is converted to lowercase.
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
# Normal-file Metadata Document ID Validation and Repair
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Problem and scope
|
||||
|
||||
A normal-file Metadata document is addressed by an ID derived from its recorded
|
||||
Vault-relative path. Historical data can contain a readable Metadata document
|
||||
whose actual local database ID no longer matches that derivation. An ordinary
|
||||
path-based read then looks up a different ID. It may reach a separate,
|
||||
consistently addressed Metadata document, or it may find no document at all;
|
||||
it cannot reach the malformed document which the Offline Scanner enumerated.
|
||||
|
||||
The mismatch can repeatedly produce failed reflection, and an offline-deletion
|
||||
decision can be made before that failure. The scanner must therefore recognise
|
||||
the mismatch before any file reflection, database deletion, expired-history
|
||||
cleanup, or last-seen update.
|
||||
|
||||
This design covers ordinary Vault files. Hidden File Sync, Customisation Sync,
|
||||
and the obsolete plug-in storage namespace retain their feature-specific
|
||||
processing. A disagreement between the document-ID namespace and recorded-path
|
||||
namespace is reported, but is not repaired by this workflow.
|
||||
|
||||
## Evidence and cause boundary
|
||||
|
||||
The reported data included readable paths which could be enumerated from
|
||||
Metadata but could not be fetched again through the path-derived lookup. The
|
||||
Vault also had a history of case changes in folder names. This is consistent
|
||||
with an ID/path mismatch, but it does not prove whether a historical rename,
|
||||
interrupted migration, or earlier path-setting change created it.
|
||||
|
||||
The repair workflow must not infer that the current path is authoritative merely
|
||||
because it is readable. It is available only when the local evidence is
|
||||
unambiguous and current.
|
||||
|
||||
## Identity invariant
|
||||
|
||||
For normal-file Metadata:
|
||||
|
||||
actualDocumentId === path2id(declaredPath)
|
||||
|
||||
The active path service owns the derivation. In particular,
|
||||
handleFilenameCaseSensitive, usePathObfuscation, and the path-obfuscation
|
||||
passphrase can change the expected ID. The E2EE Security Seed and Chunk settings
|
||||
do not directly participate in this ID.
|
||||
|
||||
Inspection and repair use the current local path service. They do not query the
|
||||
remote or decide whether this device's settings should become authoritative.
|
||||
Commonlib recalculates the expected ID during its pre-mutation inspection, so a
|
||||
local ID-derivation setting change makes an earlier approval stale. An
|
||||
intentional whole-database change to ID-derivation settings requires the
|
||||
established rebuild workflow, not this one-entry repair.
|
||||
|
||||
## Offline Scanner decision
|
||||
|
||||
The Offline Scanner validates each decoded Metadata document while its actual ID
|
||||
is still available. It does this before target-file policy and path-keyed pair
|
||||
construction.
|
||||
|
||||
- Consistent normal-file Metadata continues through the existing scan.
|
||||
- Consistent special-namespace Metadata remains owned by its feature.
|
||||
- An ID/path or namespace mismatch is left unchanged and does not enter pair
|
||||
processing.
|
||||
- If consistent, selected Metadata represents the same case-normalised path,
|
||||
that Metadata and its storage file continue through the established
|
||||
path-based scan. A stale enumerated document must not suppress this flow.
|
||||
- If no consistent, selected Metadata represents that logical path, its storage
|
||||
entry is also withheld. No storage write, database deletion, or last-seen
|
||||
update is performed for that withheld path.
|
||||
- Expired logical deletion history is not hard-tombstoned while its identity is
|
||||
inconsistent.
|
||||
|
||||
The ordinary scan still returns its established Boolean execution result. A
|
||||
recognised mismatch is a quarantined input, not a new public scan result and not
|
||||
a Fast Setup or CLI policy. Detailed inspection is deliberately separate.
|
||||
|
||||
## Inspection decision
|
||||
|
||||
inspectMetadataDocumentIdentities is read-only and enumerates the local database
|
||||
by actual document ID. This is necessary because a path-first Inspector cannot
|
||||
discover the malformed source.
|
||||
|
||||
The existing **Inspect conflicts and file/database differences** interface shows
|
||||
one card for each mismatch. It excludes that card's path from ordinary
|
||||
path-based repair only when no consistently addressed Metadata document can be
|
||||
resolved for the same logical path. A stale entry does not hide the normal
|
||||
inspection of a resolvable entry. Multiple affected files are presented
|
||||
separately; there is no batch repair.
|
||||
|
||||
A one-entry repair is offered only when all of these checks pass:
|
||||
|
||||
- the mismatch is within the normal-file namespace;
|
||||
- the source is the current live revision and has no conflicts;
|
||||
- the recorded path is valid and selected by current synchronisation policy;
|
||||
- one case-normalised path maps to one source under the active filename setting;
|
||||
- only one malformed source expects the target ID; and
|
||||
- the target ID is absent, or contains an exact structural copy left by an
|
||||
earlier attempt.
|
||||
|
||||
An exact structural copy has the same path, timestamps, size, type, Chunk
|
||||
references, Eden data, and logical-deletion state. Inspection does not fetch
|
||||
Chunk content or query the remote. Missing content remains the responsibility
|
||||
of the existing file and Chunk repair tools.
|
||||
|
||||
## Repair decision
|
||||
|
||||
The user explicitly confirms one actual ID, expected ID, and source revision.
|
||||
Commonlib then:
|
||||
|
||||
1. acquires the existing ordered document locks for the source and target IDs;
|
||||
2. reruns the complete inspection and rejects stale or unsafe input;
|
||||
3. reads the exact approved source revision;
|
||||
4. removes the path from the Offline Scanner's durable last-seen map;
|
||||
5. writes the expected target ID when it is absent;
|
||||
6. reads the target back and verifies the exact structural copy;
|
||||
7. hard-tombstones the source using the approved source revision; and
|
||||
8. returns control to LiveSync, which requests an ordinary Vault scan.
|
||||
|
||||
The repair result and the follow-up scan result remain separate. If the scan is
|
||||
suspended, returns false, or raises an error after the source has been removed,
|
||||
LiveSync reports that the identity repair completed and directs the operator to
|
||||
run the ordinary scan separately. It does not describe the completed mutation
|
||||
as a failed or rolled-back repair.
|
||||
|
||||
The target is always verified before the source is removed. If target creation
|
||||
fails, the source remains. If source removal fails, the exact target remains and
|
||||
the same one-entry action can finish the operation after a new inspection. This
|
||||
retry property is an implementation safety guarantee, not a separate public
|
||||
repair mode.
|
||||
|
||||
The target receives new CouchDB revision ancestry because ancestry cannot move
|
||||
between document IDs. Users are told to back up the device, pause editing and
|
||||
synchronisation on other devices, allow the change to replicate, and inspect
|
||||
again.
|
||||
|
||||
## Case handling
|
||||
|
||||
The active handleFilenameCaseSensitive setting defines whether path claims are
|
||||
folded before ambiguity is assessed. When case-insensitive handling is active,
|
||||
a consistently addressed entry may continue through the existing path-based
|
||||
flow even if a stale case variant is also reported. The stale entry is not
|
||||
automatically selected or removed unless the one-entry repair preconditions
|
||||
hold. When case-sensitive handling is active, intentional variants remain
|
||||
distinct.
|
||||
|
||||
This workflow does not rename Vault files or folders, infer a preferred folder
|
||||
name from one device, or coordinate a repair across devices. The repair changes
|
||||
one local database and relies on ordinary replication afterwards. Other devices
|
||||
must remain paused until that result has replicated and a new inspection is
|
||||
clean.
|
||||
|
||||
For widespread cross-device naming differences, the operator must choose an
|
||||
authoritative Vault, stop every participating device, correct its storage names
|
||||
outside Obsidian, rebuild the central remote from that Vault, and reset the
|
||||
other devices from the verified remote. During Fast Setup on an empty Vault,
|
||||
there are no storage names to correct: the scanner reflects every consistently
|
||||
addressable Metadata entry and reports only the references which remain
|
||||
unresolved.
|
||||
|
||||
## Non-goals
|
||||
|
||||
This change does not:
|
||||
|
||||
- repair several entries automatically or in a batch;
|
||||
- choose between competing case variants;
|
||||
- rename storage files or folders;
|
||||
- coordinate a distributed repair across devices;
|
||||
- migrate an entire database after path-obfuscation or case-setting changes;
|
||||
- repair special-namespace Metadata;
|
||||
- reconstruct unavailable Chunk content;
|
||||
- query or modify the remote directly; or
|
||||
- change Fast Setup, daemon, or CLI completion policy.
|
||||
|
||||
## Verification
|
||||
|
||||
Focused Commonlib tests cover early quarantine, continued processing of a
|
||||
resolvable same-path entry, expired logical-deletion retention, namespace
|
||||
routing, read-only actual-ID inspection, repair preconditions, target-first
|
||||
ordering, stale approval, exact-target retry, source preservation on failure,
|
||||
and last-seen clearing. LiveSync tests cover selective presentation-path
|
||||
withholding, separate confirmation, cancellation, and the ordinary scan request
|
||||
after a completed repair.
|
||||
@@ -54,6 +54,25 @@ The `Hatch` recovery controls are ordered by escalation. Running **Recreate chun
|
||||
|
||||
An absent Vault file and a logical-deletion winner already agree and do not require a repair card unless another live branch remains. If the scan reports many unrelated files, or the local database itself is incomplete or corrupt, stop the per-file workflow and use [Reset synchronisation on this device](#reset-synchronisation-on-this-device) from a trusted remote. If the central remote must instead be reconstructed from an authoritative Vault, use [Overwrite server data with this device's files](#overwrite-server-data-with-this-devices-files).
|
||||
|
||||
Metadata document-ID mismatches use a separate action in the same Inspector. Follow [Repair a Metadata document ID mismatch](#repair-a-metadata-document-id-mismatch) rather than applying a file revision by path.
|
||||
|
||||
## Repair a Metadata document ID mismatch
|
||||
|
||||
Use this workflow when **Inspect conflicts and file/database differences** reports `Metadata entry requires review and was left unchanged`. The Inspector found local Metadata whose stored document ID no longer represents its recorded path. It leaves the entry unchanged, while any consistently addressed Metadata for the same logical path remains available to ordinary inspection and Vault reflection. This inspection does not query the remote.
|
||||
|
||||
1. Back up this device. If other devices share the database, stop editing and pause synchronisation on them.
|
||||
2. Confirm that the current file-name case and path obfuscation settings are intended for this database. If either setting was deliberately changed for the whole database, stop this workflow and use Rebuild instead.
|
||||
3. Open **Self-hosted LiveSync settings** → **Hatch** → **Inspect conflicts and file/database differences**, then select **Begin inspection**.
|
||||
4. Find the affected Metadata card and review its recorded path, stored document ID, expected document ID, and source revision.
|
||||
5. Continue only when the card says `Repair is available for this entry.` Open its wrench menu and select **Repair this Metadata document ID**. If the action is unavailable, do not force an ID: the entry is ambiguous, conflicted, deleted, outside the normal-file namespace, or otherwise unsafe for one-entry repair.
|
||||
6. Review the warning and select **Repair Metadata ID**. LiveSync rechecks the source revision and expected ID, writes and verifies the target, then removes the obsolete ID.
|
||||
7. Wait for the ordinary Vault scan to complete. If LiveSync reports that the repair completed but the scan did not run, keep synchronisation paused, resolve the reported scan condition, then run the **Scan storage and database again** command.
|
||||
8. Allow this device to upload the repair. Resume the other devices one at a time, then run the inspection again and confirm that the Metadata card no longer appears and the Vault file has the intended content.
|
||||
|
||||
This action changes one local database entry. It does not rename Vault files or folders, repair several entries at once, coordinate other devices, or preserve CouchDB revision ancestry across the two document IDs.
|
||||
|
||||
If many entries reflect folder-name differences across devices, stop every device, choose the authoritative Vault, close Obsidian, correct the actual storage names with operating-system tools, then rebuild the central remote from that Vault and reset the other devices. During Fast Setup on an empty Vault, there are no storage names to correct: allow consistently addressable Metadata to be reflected, then inspect any remaining unresolved references.
|
||||
|
||||
## Reset synchronisation on this device
|
||||
|
||||
Use this when the remote copy is trusted but this device's local LiveSync database is incomplete, corrupt, or no longer aligned with it.
|
||||
|
||||
@@ -749,6 +749,8 @@ Compare each Vault file with every current live revision in the local database.
|
||||
|
||||
Select **Begin inspection** to run the inspection. Each reported file and live revision has a wrench menu for read-only comparison, applying an exact database revision to the Vault, recording an exact byte match, preserving the Vault file as a child of a selected branch, retrying chunk retrieval, or explicitly discarding a branch. Destructive actions require confirmation. Follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file) before changing revision history.
|
||||
|
||||
The same inspection also reports local Metadata whose stored document ID does not agree with its recorded path. A stale entry does not suppress ordinary inspection when consistently addressed Metadata can still be resolved for that logical path; otherwise, the unresolved path is excluded from ordinary file-repair actions. When one live, unconflicted entry has an unambiguous target, its wrench menu offers a separately confirmed, one-entry repair. The target is derived from the current local file-name case and path obfuscation settings, then written and verified before the obsolete ID is removed. Ambiguous, conflicted, deleted, excluded, or otherwise unsafe entries remain read-only. This action does not rename Vault files or folders. Follow [Repair a Metadata document ID mismatch](recovery.md#repair-a-metadata-document-id-mismatch) for the complete backup, repair, propagation, and verification procedure. For widespread naming differences across devices, use that guide to choose an authoritative Vault, correct its storage names while Obsidian is closed, rebuild the central remote, and reset the other devices.
|
||||
|
||||
#### Resolve All conflicted files by the newer one
|
||||
|
||||
After confirmation, resolve every conflict by modification time. This logically deletes every version except the newest one. It is a destructive policy choice and cannot recover content which is already unavailable.
|
||||
|
||||
+35
-115
@@ -1,145 +1,65 @@
|
||||
# How to report an issue
|
||||
|
||||
Thank you for helping improve Self-hosted LiveSync!
|
||||
Thank you for helping improve Self-hosted LiveSync. A concise report with the right evidence is more useful than trying several recovery operations before reporting the original symptom.
|
||||
|
||||
This document explains how to collect the information needed for an issue report. Issues with sufficient information will be prioritised.
|
||||
Use the [issue report template](https://github.com/vrtmrz/obsidian-livesync/issues/new?template=issue-report.md) for the report itself. Use [Troubleshooting](troubleshooting.md) to diagnose a symptom or choose a recovery action.
|
||||
|
||||
---
|
||||
## Preserve the original symptom
|
||||
|
||||
## Filled example
|
||||
Do not reset a database, rebuild a remote, change transport, or enable P2P merely to see whether the problem disappears. These actions can change the evidence and may make the original cause harder to identify.
|
||||
|
||||
Here is an example of a well-filled report for reference.
|
||||
If the problem may involve data loss, corruption, or unexpected deletion, preserve a copy of every readable affected file and stop editing it on other devices before changing settings.
|
||||
|
||||
### Abstract
|
||||
Include when the problem began, whether it followed an update or restart, how often it occurs, and which device and remote type were involved.
|
||||
|
||||
The synchronisation hung up immediately after connecting.
|
||||
## Required information
|
||||
|
||||
### Expected behaviour
|
||||
### Describe the behaviour
|
||||
|
||||
- Synchronisation ends with the message `Replication completed`
|
||||
- Everything synchronised
|
||||
Complete the issue template with:
|
||||
|
||||
### Actually happened
|
||||
- a one- or two-sentence summary;
|
||||
- the expected and actual behaviour;
|
||||
- repeatable steps, or the frequency and timing when reliable reproduction is not available; and
|
||||
- the role of each relevant device, such as the device where the change originated and the device where the failure appeared.
|
||||
|
||||
- Synchronisation was cancelled with the message `TypeError: Failed to fetch` (visible in the plug-in log around lines 10–12)
|
||||
- No files synchronised
|
||||
### Obsidian debug information
|
||||
|
||||
### Reproducing procedure
|
||||
Open the command palette with `Ctrl`+`P` or `Command`+`P`, run `Show debug info`, and include its output for each relevant device. The device where the problem appeared is required. Information from the other participating devices is particularly useful for synchronisation problems.
|
||||
|
||||
1. Configure LiveSync with the settings shown in the attached report.
|
||||
2. Click the sync button on the ribbon.
|
||||
3. Synchronisation begins.
|
||||
4. About two or three seconds later, the error `TypeError: Failed to fetch` appears.
|
||||
5. Replication stops. No files synchronised.
|
||||
### Full LiveSync report
|
||||
|
||||
### Obsidian debug info (Device 1 — Windows desktop)
|
||||
Run `Generate full report for opening the issue with debug info` on the device where the problem appeared. For a synchronisation problem, also collect a report from another participating device when its settings or logs are relevant. The command copies the current LiveSync settings summary and up to 1,000 recent log lines. It collects verbose log lines even when `Verbose Log` is disabled, so you do not need to enable that setting before reproducing the problem.
|
||||
|
||||
```
|
||||
SYSTEM INFO:
|
||||
Obsidian version: v1.2.8
|
||||
Installer version: v1.1.15
|
||||
Operating system: Windows 10 Pro 10.0.19044
|
||||
Login status: logged in
|
||||
Catalyst license: supporter
|
||||
Insider build toggle: off
|
||||
Community theme: Minimal v6.1.11
|
||||
Snippets enabled: 3
|
||||
Restricted mode: off
|
||||
Plugins installed: 35
|
||||
Plugins enabled: 11
|
||||
1: Self-hosted LiveSync v0.19.4
|
||||
...
|
||||
```
|
||||
The command automatically redacts known credential fields in the settings summary. It cannot guarantee that private text in log messages or unrecognised configuration fields is removed. Review the complete output before sharing it. Remove or replace:
|
||||
|
||||
### Report from LiveSync
|
||||
- usernames, passwords, passphrases, tokens, keys, and custom headers;
|
||||
- private server URLs, network addresses, database names, bucket names, room identifiers, and relay details;
|
||||
- Vault names, device names, and file paths; and
|
||||
- file contents or other private text which appears in a log message.
|
||||
|
||||
```
|
||||
----remote config----
|
||||
cors:
|
||||
credentials: "true"
|
||||
...
|
||||
---- Plug-in config ---
|
||||
couchDB_URI: self-hosted
|
||||
couchDB_USER: 𝑅𝐸𝐷𝐴𝐶𝑇𝐸𝐷
|
||||
...
|
||||
```
|
||||
Document and chunk identifiers can also be private metadata, but they may be necessary for diagnosing file reconstruction and chunk availability. Decide deliberately whether to share them. If you remove them, state that the report was redacted and that this may limit the diagnosis.
|
||||
|
||||
### Plug-in log
|
||||
For a large report, you may share a GitHub Gist after reviewing and redacting it. Deleting a Gist later cannot undo information which has already been disclosed.
|
||||
|
||||
```
|
||||
2023/5/24 10:50:33->HTTP:GET to:/ -> failed
|
||||
2023/5/24 10:50:33->TypeError:Failed to fetch
|
||||
2023/5/24 10:50:33->could not connect to https://example.com/ : your vault
|
||||
(TypeError:Failed to fetch)
|
||||
```
|
||||
## Additional evidence when relevant
|
||||
|
||||
---
|
||||
### A problem involving one file
|
||||
|
||||
## How to collect each piece of information
|
||||
Run `Copy database information for the active file`, or use **Hatch** → **Copy database information for a file** to select another file.
|
||||
|
||||
### Obsidian debug info
|
||||
This report describes only the local database on that device. It includes the Vault-relative path, document and chunk identifiers, local revisions, conflicts, and local chunk availability. It does not query the remote or include file contents. Review paths and identifiers as private metadata before sharing them.
|
||||
|
||||
Open the command palette (`Ctrl/Cmd + P`) and run **"Show debug info"**. Copy the output and paste it into the issue.
|
||||
### A problem which crosses a restart
|
||||
|
||||
If multiple devices are involved in the problem (e.g., sync between a phone and a desktop), please provide the debug info for each device. The device where the issue occurred is required; information from other devices is strongly recommended.
|
||||
Use `Write logs into the file` under **Hatch** only when the in-memory report cannot cover the restart. Persistent logging affects performance and can record private information. Disable it after reproducing the problem, review the log before sharing it, and remove the log file when it is no longer needed.
|
||||
|
||||
### Report from LiveSync (hatch report)
|
||||
### A connection, authentication, or CORS problem
|
||||
|
||||
1. Open LiveSync settings.
|
||||
2. Go to the **Hatch** pane.
|
||||
3. Press the **Make report** button.
|
||||
Include network evidence only when the ordinary LiveSync log cannot show the rejected response. Follow [Inspect a network failure](troubleshooting.md#inspect-a-network-failure), and remove request paths, remote addresses, authority and authorisation values, cookies, credentials, payload identifiers, and response secrets before sharing screenshots or copied data.
|
||||
|
||||
The report will be copied to your clipboard. It contains your LiveSync configuration and the remote server configuration, with credentials automatically redacted.
|
||||
## Sharing the report
|
||||
|
||||
**Tip:** For large reports, consider uploading to [GitHub Gist](https://gist.github.com/) and sharing the link instead of pasting directly into the issue. This makes it easier to manage, and if you accidentally leave sensitive data in, a Gist can be deleted.
|
||||
Paste reports into the matching collapsible sections in the issue template, or provide a link to an already-redacted Gist. A separate plug-in log is normally unnecessary because the full LiveSync report already contains the recent verbose log history.
|
||||
|
||||
If you paste directly, wrap it in a `<details>` tag to keep the issue readable:
|
||||
|
||||
```
|
||||
<details>
|
||||
<summary>Report from hatch</summary>
|
||||
|
||||
```
|
||||
----remote config----
|
||||
:
|
||||
```
|
||||
</details>
|
||||
```
|
||||
|
||||
### Plug-in log
|
||||
|
||||
The plug-in log is volatile by default (not saved to disk) and shown only in the log dialogue, which can be opened by tapping the **document box icon** in the ribbon.
|
||||
|
||||
#### Enable verbose log
|
||||
|
||||
Before reproducing the issue, enable **Verbose Log** in LiveSync's **General Settings** pane. Without this, many diagnostic messages will be suppressed.
|
||||
|
||||
#### Persist the log to a file (optional)
|
||||
|
||||
If you need to capture a log across a restart, enable **"Write logs into the file"** in General Settings. Note that log files may contain sensitive information — use this option only for troubleshooting, and disable it afterwards.
|
||||
|
||||
As with the hatch report, consider uploading large logs to [GitHub Gist](https://gist.github.com/).
|
||||
|
||||
### Network log (for connection-related issues only)
|
||||
|
||||
If the issue is related to network connectivity (e.g., cannot connect to the server, authentication errors), a network log captured from browser DevTools can be very helpful. You do not need to include this for non-connection issues.
|
||||
|
||||
#### Opening DevTools
|
||||
|
||||
| Platform | Shortcut |
|
||||
|----------|----------|
|
||||
| Windows / Linux | `Ctrl + Shift + I` |
|
||||
| macOS | `Cmd + Shift + I` |
|
||||
| Android | Use [Chrome remote debugging](https://developer.chrome.com/docs/devtools/remote-debugging/) |
|
||||
| iOS | Use [Safari Web Inspector](https://developer.apple.com/documentation/safari-developer-tools/inspecting-ios) on a Mac |
|
||||
|
||||
#### What to capture
|
||||
|
||||
1. Open the **Network** pane in DevTools.
|
||||
2. Reproduce the issue.
|
||||
3. Look for requests marked in red.
|
||||
4. Capture screenshots of the **Headers**, **Payload**, and **Response** tabs for those requests.
|
||||
|
||||
**Important — redact before sharing:**
|
||||
- Headers: conceal the request URL path, Remote Address, `authority`, and `authorisation` values.
|
||||
- Payload / Response: the `_id` field contains your file paths — redact if needed.
|
||||
If a maintainer asks for a more specialised diagnostic, collect only that additional evidence and review it again before publishing it.
|
||||
|
||||
@@ -66,6 +66,14 @@ The repair card uses compact diagnostic rows which remain readable in a narrow m
|
||||
|
||||
`Recreate chunks for current Vault files` uses current Vault content. It cannot recreate unique bytes which exist only in an unreadable historical or conflict revision.
|
||||
|
||||
## A Metadata entry requires review
|
||||
|
||||
When **Inspect conflicts and file/database differences** reports `Metadata entry requires review and was left unchanged`, the local database contains Metadata whose stored document ID does not agree with the ID derived from its recorded path. LiveSync withholds that entry from ordinary file reflection and deletion rather than guessing which identity is intended. The inspection is local and does not query the remote.
|
||||
|
||||
Do not change file-name case handling or path obfuscation merely to make the displayed IDs agree. Follow [Repair a Metadata document ID mismatch](recovery.md#repair-a-metadata-document-id-mismatch) when the card offers **Repair this Metadata document ID**. If no repair action is offered, the entry is ambiguous, conflicted, deleted, outside the normal-file namespace, or otherwise unsafe for one-entry repair. Preserve the evidence and use the wider recovery guidance instead of forcing a target ID.
|
||||
|
||||
If many entries reflect deliberate folder-name or ID-derivation differences across devices, choose an authoritative Vault and use the established Rebuild workflow. A one-entry repair is not a distributed rename or database migration.
|
||||
|
||||
## A configuration mismatch dialogue blocks synchronisation
|
||||
|
||||
Some settings must match across devices. LiveSync pauses synchronisation when the local and remote values differ rather than propagating an unexpected change silently.
|
||||
@@ -117,6 +125,8 @@ Enable Obsidian's `Detect all file extensions`, then check LiveSync selectors, i
|
||||
|
||||
## Collect a report
|
||||
|
||||
Follow [How to report an issue](to_issue_reporting.md) for the complete reporting checklist, including Obsidian debug information and the privacy review required before sharing evidence.
|
||||
|
||||
Run `Generate full report for opening the issue with debug info` to copy the current settings summary and recent verbose log lines. Remove credentials, remote URLs, Vault names, file contents, and other private information before sharing it.
|
||||
|
||||
When a problem concerns one file, run **Copy database information for the active file**, or use **Hatch** → **Copy database information for a file** to select another file. The report describes this device's local database view, including the Vault-relative path, document and chunk identifiers, local database revisions, conflicts, and local chunk availability. It does not query the remote server or include file contents. Treat paths and identifiers as private metadata before sharing.
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.12",
|
||||
"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
+9
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.12",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"src/apps/cli",
|
||||
@@ -23,7 +23,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.8",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.12",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
@@ -4775,9 +4775,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.8.tgz",
|
||||
"integrity": "sha512-Kn1AF41h2Dog37ThU7KgLcKxItCCerLEBWg1eSGAUoTk3TyPBYynvtmVFwWhy6LCePcuwB/+x7EQNTL3Sgzkyg==",
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.12.tgz",
|
||||
"integrity": "sha512-SYqUsMcz9241qEUqmnIwVKOPXJB4/FXjXQh8nHPmX004KkV5PUnAm4ClhNwlQzvYfpTI5IHYKej+JIndiQYXmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.808.0",
|
||||
@@ -15924,7 +15924,7 @@
|
||||
},
|
||||
"src/apps/cli": {
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"version": "1.0.9-cli",
|
||||
"version": "1.0.12-cli",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
@@ -15949,7 +15949,7 @@
|
||||
},
|
||||
"src/apps/webapp": {
|
||||
"name": "livesync-webapp",
|
||||
"version": "1.0.9-webapp",
|
||||
"version": "1.0.12-webapp",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
@@ -15961,7 +15961,7 @@
|
||||
}
|
||||
},
|
||||
"src/apps/webpeer": {
|
||||
"version": "1.0.9-webpeer",
|
||||
"version": "1.0.12-webpeer",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.12",
|
||||
"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",
|
||||
@@ -177,7 +177,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.8",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.12",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
|
||||
@@ -121,8 +121,10 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
||||
return {
|
||||
path: path.relative(this.basePath, filePath).replace(/\\/g, "/") as FilePath,
|
||||
stat: {
|
||||
ctime: stats?.ctimeMs ?? Date.now(),
|
||||
mtime: stats?.mtimeMs ?? Date.now(),
|
||||
// Floor to integer milliseconds; Linux fs.Stats.*Ms carry sub-millisecond
|
||||
// precision, and timestamps are stored as integer ms everywhere else.
|
||||
ctime: Math.floor(stats?.ctimeMs ?? Date.now()),
|
||||
mtime: Math.floor(stats?.mtimeMs ?? Date.now()),
|
||||
size: stats?.size ?? 0,
|
||||
type: "file",
|
||||
},
|
||||
|
||||
@@ -84,6 +84,27 @@ describe("CLIStorageEventManagerAdapter", () => {
|
||||
expect(created.stat?.size).toBe(42);
|
||||
});
|
||||
|
||||
it("floors sub-millisecond stat timestamps so mobile clients do not receive floats", async () => {
|
||||
const basePath = "/vault/base";
|
||||
const adapter = new CLIStorageEventManagerAdapter(basePath, undefined, true);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
await adapter.watch.beginWatch(handlers);
|
||||
|
||||
const addCallback = mockWatcher.on.mock.calls.find(([event]) => event === "add")![1] as (
|
||||
filePath: string,
|
||||
stats: any
|
||||
) => void;
|
||||
|
||||
// Linux fs.Stats carry nanosecond-derived sub-millisecond precision.
|
||||
const floatStats = { ctimeMs: 1778511180024.462, mtimeMs: 1778511180999.913, size: 7 };
|
||||
addCallback(`${basePath}/note.md`, floatStats);
|
||||
|
||||
const created = (handlers.onCreate as ReturnType<typeof vi.fn>).mock.calls[0][0] as NodeFile;
|
||||
expect(created.stat?.ctime).toBe(1778511180024);
|
||||
expect(created.stat?.mtime).toBe(1778511180999);
|
||||
});
|
||||
|
||||
it("close() calls watcher.close()", async () => {
|
||||
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.9-cli",
|
||||
"version": "1.0.12-cli",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.9-webapp",
|
||||
"version": "1.0.12-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.9-webpeer",
|
||||
"version": "1.0.12-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -150,9 +150,38 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.",
|
||||
"Resolve all conflicts by the newest version": "Resolve all conflicts by the newest version",
|
||||
"Inspect conflicts and file/database differences": "Inspect conflicts and file/database differences",
|
||||
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.":
|
||||
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.",
|
||||
"Scan Vault files and local-database Metadata for conflicts, missing chunks, identity mismatches, and differences. Each result provides actions for one exact entry or revision.":
|
||||
"Scan Vault files and local-database Metadata for conflicts, missing chunks, identity mismatches, and differences. Each result provides actions for one exact entry or revision.",
|
||||
"Begin inspection": "Begin inspection",
|
||||
"Metadata entry requires review and was left unchanged": "Metadata entry requires review and was left unchanged",
|
||||
"The stored document ID does not match the ID derived from its recorded path.":
|
||||
"The stored document ID does not match the ID derived from its recorded path.",
|
||||
"The stored document ID and recorded path are handled by different synchronisation features.":
|
||||
"The stored document ID and recorded path are handled by different synchronisation features.",
|
||||
"Stored document ID: ${ID}": "Stored document ID: ${ID}",
|
||||
"Expected document ID: ${ID}": "Expected document ID: ${ID}",
|
||||
"Source revision: ${REVISION}": "Source revision: ${REVISION}",
|
||||
"One-step repair is unavailable because this entry is ambiguous, no longer current, or unsafe to change.":
|
||||
"One-step repair is unavailable because this entry is ambiguous, no longer current, or unsafe to change.",
|
||||
"An exact target is already present; repair can remove the obsolete ID.":
|
||||
"An exact target is already present; repair can remove the obsolete ID.",
|
||||
"Repair is available for this entry.": "Repair is available for this entry.",
|
||||
"Repair this Metadata document ID": "Repair this Metadata document ID",
|
||||
"Repair Metadata ID": "Repair Metadata ID",
|
||||
"Keep unchanged": "Keep unchanged",
|
||||
"Repair Metadata document ID": "Repair Metadata document ID",
|
||||
"This moves one local Metadata entry to the ID derived from its recorded path.\n\n**File:** `${FILE}` \n**Source:** `${SOURCE}@${REVISION}` \n**Target:** `${TARGET}`\n\nThe target is verified before the source is removed. Its CouchDB revision ancestry cannot be preserved.\n\n> [!warning] Before repairing\n> - Back up this device.\n> - If file-name case or path obfuscation was intentionally changed for the whole database, use Rebuild instead.\n> - If other devices share this database, pause them, allow this device to upload the repair, then resume them one at a time.":
|
||||
"This moves one local Metadata entry to the ID derived from its recorded path.\n\n**File:** `${FILE}` \n**Source:** `${SOURCE}@${REVISION}` \n**Target:** `${TARGET}`\n\nThe target is verified before the source is removed. Its CouchDB revision ancestry cannot be preserved.\n\n> [!warning] Before repairing\n> - Back up this device.\n> - If file-name case or path obfuscation was intentionally changed for the whole database, use Rebuild instead.\n> - If other devices share this database, pause them, allow this device to upload the repair, then resume them one at a time.",
|
||||
"Metadata document ID repair and the ordinary Vault scan completed. Run this inspection again after synchronisation.":
|
||||
"Metadata document ID repair and the ordinary Vault scan completed. Run this inspection again after synchronisation.",
|
||||
"Metadata document ID repair completed, but the ordinary Vault scan did not run. Keep synchronisation paused, resolve the scan condition, then run 'Scan storage and database again'.":
|
||||
"Metadata document ID repair completed, but the ordinary Vault scan did not run. Keep synchronisation paused, resolve the scan condition, then run 'Scan storage and database again'.",
|
||||
"The inspected state changed. No repair was performed; run inspection again.":
|
||||
"The inspected state changed. No repair was performed; run inspection again.",
|
||||
"Repair stopped after creating the target. The source was retained. Run inspection again before retrying.":
|
||||
"Repair stopped after creating the target. The source was retained. Run inspection again before retrying.",
|
||||
"Repair failed before the source was removed. Run inspection again before retrying.":
|
||||
"Repair failed before the source was removed. Run inspection again before retrying.",
|
||||
"Connection settings": "Connection settings",
|
||||
"Saved connections": "Saved connections",
|
||||
} as const;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type EntryDoc,
|
||||
type diff_result,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createBlob, readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { createBlob, escapeMarkdownValue, readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { shouldBeIgnored } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { Menu, diff_match_patch, setIcon } from "@/deps.ts";
|
||||
@@ -44,6 +44,21 @@ import {
|
||||
getFileRepairRevisionComparison,
|
||||
} from "@/serviceFeatures/fileRepairPresentation.ts";
|
||||
import { ConflictResolveModal } from "@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
|
||||
import {
|
||||
inspectMetadataDocumentIdentities,
|
||||
MetadataDocumentRepairResults,
|
||||
OfflineScanUnresolvedReasons,
|
||||
repairMetadataDocumentIdentity,
|
||||
type MetadataDocumentIdentityIssue,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import {
|
||||
metadataIdentityPathKey,
|
||||
selectUnresolvedMetadataIdentityEntries,
|
||||
} from "@/serviceFeatures/metadataIdentityInspection.ts";
|
||||
import {
|
||||
executeMetadataIdentityRepair,
|
||||
MetadataIdentityRepairExecutions,
|
||||
} from "@/serviceFeatures/metadataIdentityRepair.ts";
|
||||
export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
|
||||
// const hatchWarn = this.createEl(paneEl, "div", { text: `To stop the boot up sequence for fixing problems on databases, you can put redflag.md on top of your vault (Rebooting obsidian is required).` });
|
||||
// hatchWarn.addClass("op-warn-info");
|
||||
@@ -178,6 +193,150 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
});
|
||||
});
|
||||
};
|
||||
const addMetadataIdentityResult = (entry: MetadataDocumentIdentityIssue) => {
|
||||
const { diagnostic } = entry.inspection;
|
||||
const card = this.createEl(resultArea, "div", { cls: "sls-repair-result" });
|
||||
this.createEl(card, "h6", { text: diagnostic.declaredPath });
|
||||
this.createEl(card, "div", {
|
||||
text: $msg("Metadata entry requires review and was left unchanged"),
|
||||
cls: "sls-repair-status-warning",
|
||||
});
|
||||
this.createEl(card, "div", {
|
||||
text:
|
||||
diagnostic.reason === OfflineScanUnresolvedReasons.DOCUMENT_ID_MISMATCH
|
||||
? $msg("The stored document ID does not match the ID derived from its recorded path.")
|
||||
: $msg(
|
||||
"The stored document ID and recorded path are handled by different synchronisation features."
|
||||
),
|
||||
cls: "sls-repair-metric",
|
||||
});
|
||||
this.createEl(card, "div", {
|
||||
text: $msg("Stored document ID: ${ID}", { ID: diagnostic.actualDocumentId }),
|
||||
cls: "sls-repair-metric",
|
||||
});
|
||||
if (diagnostic.expectedDocumentId !== undefined) {
|
||||
this.createEl(card, "div", {
|
||||
text: $msg("Expected document ID: ${ID}", { ID: diagnostic.expectedDocumentId }),
|
||||
cls: "sls-repair-metric",
|
||||
});
|
||||
}
|
||||
this.createEl(card, "div", {
|
||||
text: $msg("Source revision: ${REVISION}", {
|
||||
REVISION: entry.sourceRevision ?? $msg("Unknown revision"),
|
||||
}),
|
||||
cls: "sls-repair-metric",
|
||||
});
|
||||
if (entry.logicallyDeleted) {
|
||||
this.createEl(card, "div", {
|
||||
text: $msg("🗑️ Logical deletion"),
|
||||
cls: "sls-repair-metric mod-warning",
|
||||
});
|
||||
}
|
||||
if (entry.conflictRevisions.length > 0) {
|
||||
this.createEl(card, "div", {
|
||||
text: $msg("⚠️ Conflicts: ${COUNT}", { COUNT: `${entry.conflictRevisions.length}` }),
|
||||
cls: "sls-repair-metric mod-warning",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!entry.repairAvailable ||
|
||||
diagnostic.expectedDocumentId === undefined ||
|
||||
entry.sourceRevision === null
|
||||
) {
|
||||
this.createEl(card, "div", {
|
||||
text: $msg(
|
||||
"One-step repair is unavailable because this entry is ambiguous, no longer current, or unsafe to change."
|
||||
),
|
||||
cls: "sls-repair-metric mod-warning",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.createEl(card, "div", {
|
||||
text: entry.targetAlreadyPresent
|
||||
? $msg("An exact target is already present; repair can remove the obsolete ID.")
|
||||
: $msg("Repair is available for this entry."),
|
||||
cls: "sls-repair-status-ok",
|
||||
});
|
||||
const request = {
|
||||
actualDocumentId: diagnostic.actualDocumentId,
|
||||
expectedDocumentId: diagnostic.expectedDocumentId,
|
||||
sourceRevision: entry.sourceRevision,
|
||||
};
|
||||
const repairAction = $msg("Repair Metadata ID");
|
||||
const keepAction = $msg("Keep unchanged");
|
||||
addActionMenu(card, $msg("More actions for ${FILE}", { FILE: diagnostic.declaredPath }), [
|
||||
{
|
||||
title: $msg("Repair this Metadata document ID"),
|
||||
warning: true,
|
||||
run: async () => {
|
||||
const execution = await executeMetadataIdentityRepair(request, {
|
||||
confirm: async () =>
|
||||
(await this.core.confirm.confirmWithMessage(
|
||||
$msg("Repair Metadata document ID"),
|
||||
$msg(
|
||||
"This moves one local Metadata entry to the ID derived from its recorded path.\n\n**File:** `${FILE}` \n**Source:** `${SOURCE}@${REVISION}` \n**Target:** `${TARGET}`\n\nThe target is verified before the source is removed. Its CouchDB revision ancestry cannot be preserved.\n\n> [!warning] Before repairing\n> - Back up this device.\n> - If file-name case or path obfuscation was intentionally changed for the whole database, use Rebuild instead.\n> - If other devices share this database, pause them, allow this device to upload the repair, then resume them one at a time.",
|
||||
{
|
||||
FILE: escapeMarkdownValue(diagnostic.declaredPath),
|
||||
SOURCE: escapeMarkdownValue(diagnostic.actualDocumentId),
|
||||
REVISION: escapeMarkdownValue(entry.sourceRevision!),
|
||||
TARGET: escapeMarkdownValue(diagnostic.expectedDocumentId!),
|
||||
}
|
||||
),
|
||||
[repairAction, keepAction],
|
||||
keepAction,
|
||||
undefined,
|
||||
"vertical"
|
||||
)) === repairAction,
|
||||
repair: async (repairRequest) =>
|
||||
await repairMetadataDocumentIdentity(this.core, repairRequest),
|
||||
requestOrdinaryScan: async () => await this.services.vault.scanVault(true, false),
|
||||
});
|
||||
|
||||
if (execution.status === MetadataIdentityRepairExecutions.CANCELLED) return;
|
||||
|
||||
const result = execution.result;
|
||||
if (result.message) Logger(result.message, LOG_LEVEL_VERBOSE);
|
||||
if (result.status === MetadataDocumentRepairResults.COMPLETED) {
|
||||
if (execution.scanError !== undefined) {
|
||||
Logger(execution.scanError, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
resultArea.replaceChildren();
|
||||
this.createEl(resultArea, "div", {
|
||||
text: execution.scanCompleted
|
||||
? $msg(
|
||||
"Metadata document ID repair and the ordinary Vault scan completed. Run this inspection again after synchronisation."
|
||||
)
|
||||
: $msg(
|
||||
"Metadata document ID repair completed, but the ordinary Vault scan did not run. Keep synchronisation paused, resolve the scan condition, then run 'Scan storage and database again'."
|
||||
),
|
||||
cls: execution.scanCompleted
|
||||
? "sls-repair-status-ok"
|
||||
: "sls-repair-metric mod-warning",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const resultMessage =
|
||||
result.status === MetadataDocumentRepairResults.STALE ||
|
||||
result.status === MetadataDocumentRepairResults.BLOCKED
|
||||
? $msg("The inspected state changed. No repair was performed; run inspection again.")
|
||||
: result.targetCreated
|
||||
? $msg(
|
||||
"Repair stopped after creating the target. The source was retained. Run inspection again before retrying."
|
||||
)
|
||||
: $msg(
|
||||
"Repair failed before the source was removed. Run inspection again before retrying."
|
||||
);
|
||||
this.createEl(card, "div", {
|
||||
text: resultMessage,
|
||||
cls: "sls-repair-metric mod-warning",
|
||||
});
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
const findHiddenFile = async (path: string) => {
|
||||
const addOn = this.core.getAddOn<HiddenFileSync>(HiddenFileSync.name);
|
||||
if (!addOn) {
|
||||
@@ -827,7 +986,7 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
.setName($msg("Inspect conflicts and file/database differences"))
|
||||
.setDesc(
|
||||
$msg(
|
||||
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision."
|
||||
"Scan Vault files and local-database Metadata for conflicts, missing chunks, identity mismatches, and differences. Each result provides actions for one exact entry or revision."
|
||||
)
|
||||
)
|
||||
.addButton((button) =>
|
||||
@@ -839,6 +998,13 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
resultArea.replaceChildren();
|
||||
Logger("Start inspecting file/database state", LOG_LEVEL_NOTICE, "verify");
|
||||
this.core.localDatabase.clearCaches();
|
||||
const identityEntries = await inspectMetadataDocumentIdentities(this.core);
|
||||
const handleFilenameCaseSensitive = this.core.settings.handleFilenameCaseSensitive;
|
||||
const unresolvedIdentity = selectUnresolvedMetadataIdentityEntries(
|
||||
identityEntries,
|
||||
handleFilenameCaseSensitive
|
||||
);
|
||||
unresolvedIdentity.entries.forEach(addMetadataIdentityResult);
|
||||
const allPaths = await collectFileDatabaseInfoPaths(this.core);
|
||||
let i = 0;
|
||||
const incProc = () => {
|
||||
@@ -853,6 +1019,13 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
const semaphore = Semaphore(10);
|
||||
const processes = allPaths.map(async (path) => {
|
||||
try {
|
||||
if (
|
||||
unresolvedIdentity.unresolvedPathKeys.has(
|
||||
metadataIdentityPathKey(path, handleFilenameCaseSensitive)
|
||||
)
|
||||
) {
|
||||
return incProc();
|
||||
}
|
||||
if (shouldBeIgnored(path)) {
|
||||
return incProc();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { MetadataDocumentIdentityIssue } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
export function metadataIdentityPathKey(path: string, handleFilenameCaseSensitive: boolean): string {
|
||||
const vaultPath = stripAllPrefixes(path as FilePathWithPrefix);
|
||||
return handleFilenameCaseSensitive ? vaultPath : vaultPath.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select unresolved identity evidence for read-only presentation and create
|
||||
* the path keys which must be withheld from ordinary path-based repair.
|
||||
*/
|
||||
export function selectUnresolvedMetadataIdentityEntries(
|
||||
entries: readonly MetadataDocumentIdentityIssue[],
|
||||
handleFilenameCaseSensitive: boolean
|
||||
): {
|
||||
entries: MetadataDocumentIdentityIssue[];
|
||||
unresolvedPathKeys: ReadonlySet<string>;
|
||||
} {
|
||||
return {
|
||||
entries: [...entries],
|
||||
unresolvedPathKeys: new Set(
|
||||
entries
|
||||
.filter(({ ordinaryPathAvailable }) => !ordinaryPathAvailable)
|
||||
.map(({ inspection }) =>
|
||||
metadataIdentityPathKey(inspection.diagnostic.declaredPath, handleFilenameCaseSensitive)
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MetadataDocumentIdentityIssue } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import { metadataIdentityPathKey, selectUnresolvedMetadataIdentityEntries } from "./metadataIdentityInspection";
|
||||
|
||||
function createEntries(): MetadataDocumentIdentityIssue[] {
|
||||
return [
|
||||
{
|
||||
inspection: {
|
||||
status: "unresolved",
|
||||
diagnostic: {
|
||||
reason: "document-id-mismatch",
|
||||
actualDocumentId: "f:stale",
|
||||
declaredPath: "Folder/Renamed.md",
|
||||
expectedDocumentId: "f:renamed",
|
||||
actualNamespace: "normal",
|
||||
declaredPathNamespace: "normal",
|
||||
},
|
||||
},
|
||||
sourceRevision: "3-stale",
|
||||
logicallyDeleted: false,
|
||||
conflictRevisions: [],
|
||||
repairAvailable: false,
|
||||
targetAlreadyPresent: false,
|
||||
ordinaryPathAvailable: false,
|
||||
},
|
||||
{
|
||||
inspection: {
|
||||
status: "unresolved",
|
||||
diagnostic: {
|
||||
reason: "namespace-mismatch",
|
||||
actualDocumentId: "f:stale-internal-path",
|
||||
declaredPath: "i:.Obsidian/App.json",
|
||||
actualNamespace: "normal",
|
||||
declaredPathNamespace: "internal",
|
||||
},
|
||||
},
|
||||
sourceRevision: "2-stale",
|
||||
logicallyDeleted: false,
|
||||
conflictRevisions: [],
|
||||
repairAvailable: false,
|
||||
targetAlreadyPresent: false,
|
||||
ordinaryPathAvailable: false,
|
||||
},
|
||||
] as unknown as MetadataDocumentIdentityIssue[];
|
||||
}
|
||||
|
||||
describe("Metadata identity inspection presentation", () => {
|
||||
it("derives case-insensitive Vault path keys for unresolved evidence", () => {
|
||||
const result = selectUnresolvedMetadataIdentityEntries(createEntries(), false);
|
||||
|
||||
expect(result.entries.map(({ sourceRevision }) => sourceRevision)).toEqual(["3-stale", "2-stale"]);
|
||||
expect([...result.unresolvedPathKeys]).toEqual(["folder/renamed.md", ".obsidian/app.json"]);
|
||||
expect(metadataIdentityPathKey("folder/RENAMED.md", false)).toBe("folder/renamed.md");
|
||||
expect(result.unresolvedPathKeys.has(metadataIdentityPathKey("folder/RENAMED.md", false))).toBe(true);
|
||||
});
|
||||
|
||||
it("retains case distinctions when filename handling is case-sensitive", () => {
|
||||
const result = selectUnresolvedMetadataIdentityEntries(createEntries(), true);
|
||||
|
||||
expect(result.unresolvedPathKeys.has("Folder/Renamed.md")).toBe(true);
|
||||
expect(result.unresolvedPathKeys.has("folder/renamed.md")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not suppress ordinary inspection when the path has resolvable Metadata", () => {
|
||||
const entries = createEntries();
|
||||
entries[0] = {
|
||||
...entries[0],
|
||||
ordinaryPathAvailable: true,
|
||||
} as MetadataDocumentIdentityIssue;
|
||||
|
||||
const result = selectUnresolvedMetadataIdentityEntries(entries, false);
|
||||
|
||||
expect(result.entries).toHaveLength(2);
|
||||
expect(result.unresolvedPathKeys.has("folder/renamed.md")).toBe(false);
|
||||
expect(result.unresolvedPathKeys.has(".obsidian/app.json")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type {
|
||||
MetadataDocumentRepairRequest,
|
||||
MetadataDocumentRepairResult,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import { MetadataDocumentRepairResults } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
|
||||
export const MetadataIdentityRepairExecutions = {
|
||||
CANCELLED: "cancelled",
|
||||
REPAIR_RESULT: "repair-result",
|
||||
} as const;
|
||||
|
||||
export type MetadataIdentityRepairExecution =
|
||||
| { status: typeof MetadataIdentityRepairExecutions.CANCELLED }
|
||||
| {
|
||||
status: typeof MetadataIdentityRepairExecutions.REPAIR_RESULT;
|
||||
result: MetadataDocumentRepairResult;
|
||||
scanCompleted: boolean;
|
||||
scanError?: unknown;
|
||||
};
|
||||
|
||||
export interface MetadataIdentityRepairDependencies {
|
||||
confirm: () => Promise<boolean>;
|
||||
repair: (request: MetadataDocumentRepairRequest) => Promise<MetadataDocumentRepairResult>;
|
||||
requestOrdinaryScan: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinate one explicitly confirmed Metadata identity repair.
|
||||
*
|
||||
* This consumer boundary deliberately keeps inspection approval, Commonlib
|
||||
* mutation, and the subsequent ordinary Vault scan as separate operations.
|
||||
* Cancellation cannot reach the mutation, and only a completed repair hands
|
||||
* reconciliation back to the Offline Scanner. Commonlib re-inspects the
|
||||
* source and expected ID under the current local path settings immediately
|
||||
* before mutation, so remote replication state is not part of this boundary.
|
||||
*/
|
||||
export async function executeMetadataIdentityRepair(
|
||||
request: MetadataDocumentRepairRequest,
|
||||
dependencies: MetadataIdentityRepairDependencies
|
||||
): Promise<MetadataIdentityRepairExecution> {
|
||||
if (!(await dependencies.confirm())) {
|
||||
return { status: MetadataIdentityRepairExecutions.CANCELLED };
|
||||
}
|
||||
const result = await dependencies.repair(request);
|
||||
if (result.status !== MetadataDocumentRepairResults.COMPLETED) {
|
||||
return {
|
||||
status: MetadataIdentityRepairExecutions.REPAIR_RESULT,
|
||||
result,
|
||||
scanCompleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const scanCompleted = await dependencies.requestOrdinaryScan();
|
||||
return {
|
||||
status: MetadataIdentityRepairExecutions.REPAIR_RESULT,
|
||||
result,
|
||||
scanCompleted,
|
||||
};
|
||||
} catch (scanError) {
|
||||
// The Metadata identity mutation has already completed. Preserve that
|
||||
// result separately so a follow-up scan failure cannot be mistaken for
|
||||
// a failed or rolled-back repair.
|
||||
return {
|
||||
status: MetadataIdentityRepairExecutions.REPAIR_RESULT,
|
||||
result,
|
||||
scanCompleted: false,
|
||||
scanError,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { DocumentID } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type {
|
||||
MetadataDocumentRepairRequest,
|
||||
MetadataDocumentRepairResult,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import { executeMetadataIdentityRepair } from "./metadataIdentityRepair";
|
||||
|
||||
const request: MetadataDocumentRepairRequest = {
|
||||
actualDocumentId: "f:stale" as DocumentID,
|
||||
expectedDocumentId: "f:expected" as DocumentID,
|
||||
sourceRevision: "4-source",
|
||||
};
|
||||
|
||||
function createDependencies() {
|
||||
const events: string[] = [];
|
||||
return {
|
||||
events,
|
||||
confirm: vi.fn(async () => true),
|
||||
repair: vi.fn(async (): Promise<MetadataDocumentRepairResult> => {
|
||||
events.push("repair");
|
||||
return {
|
||||
status: "completed" as const,
|
||||
...request,
|
||||
targetCreated: true,
|
||||
};
|
||||
}),
|
||||
requestOrdinaryScan: vi.fn(async () => {
|
||||
events.push("scan");
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("executeMetadataIdentityRepair", () => {
|
||||
it("performs no mutation when the separate confirmation is cancelled", async () => {
|
||||
const dependencies = createDependencies();
|
||||
dependencies.confirm.mockResolvedValue(false);
|
||||
|
||||
await expect(executeMetadataIdentityRepair(request, dependencies)).resolves.toEqual({
|
||||
status: "cancelled",
|
||||
});
|
||||
expect(dependencies.repair).not.toHaveBeenCalled();
|
||||
expect(dependencies.requestOrdinaryScan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requests an ordinary scan only after Commonlib completes the exact repair", async () => {
|
||||
const dependencies = createDependencies();
|
||||
|
||||
await expect(executeMetadataIdentityRepair(request, dependencies)).resolves.toMatchObject({
|
||||
status: "repair-result",
|
||||
result: { status: "completed" },
|
||||
scanCompleted: true,
|
||||
});
|
||||
expect(dependencies.repair).toHaveBeenCalledWith(request);
|
||||
expect(dependencies.requestOrdinaryScan).toHaveBeenCalledOnce();
|
||||
expect(dependencies.repair.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
dependencies.requestOrdinaryScan.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(dependencies.events).toEqual(["repair", "scan"]);
|
||||
});
|
||||
|
||||
it("keeps a completed repair distinct when the ordinary scan cannot start", async () => {
|
||||
const dependencies = createDependencies();
|
||||
dependencies.requestOrdinaryScan.mockResolvedValue(false);
|
||||
|
||||
await expect(executeMetadataIdentityRepair(request, dependencies)).resolves.toMatchObject({
|
||||
status: "repair-result",
|
||||
result: { status: "completed" },
|
||||
scanCompleted: false,
|
||||
});
|
||||
expect(dependencies.requestOrdinaryScan).toHaveBeenCalledOnce();
|
||||
expect(dependencies.events).toEqual(["repair"]);
|
||||
});
|
||||
|
||||
it("keeps a completed repair distinct when requesting the ordinary scan throws", async () => {
|
||||
const dependencies = createDependencies();
|
||||
const error = new Error("scan unavailable");
|
||||
dependencies.requestOrdinaryScan.mockRejectedValue(error);
|
||||
|
||||
await expect(executeMetadataIdentityRepair(request, dependencies)).resolves.toMatchObject({
|
||||
status: "repair-result",
|
||||
result: { status: "completed" },
|
||||
scanCompleted: false,
|
||||
scanError: error,
|
||||
});
|
||||
expect(dependencies.events).toEqual(["repair"]);
|
||||
});
|
||||
|
||||
it("does not scan after a stale, blocked, or failed repair result", async () => {
|
||||
for (const status of ["stale", "blocked", "failed"] as const) {
|
||||
const dependencies = createDependencies();
|
||||
dependencies.repair.mockResolvedValue({
|
||||
status,
|
||||
...request,
|
||||
targetCreated: false,
|
||||
});
|
||||
|
||||
await expect(executeMetadataIdentityRepair(request, dependencies)).resolves.toMatchObject({
|
||||
status: "repair-result",
|
||||
result: { status },
|
||||
scanCompleted: false,
|
||||
});
|
||||
expect(dependencies.requestOrdinaryScan).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/commo
|
||||
import type { IStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
|
||||
import { toArrayBuffer } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
|
||||
import type { Stat, App } from "obsidian";
|
||||
import { toIntegerTimestamps } from "./sanitizeWriteOptions";
|
||||
|
||||
/**
|
||||
* Storage adapter implementation for Obsidian
|
||||
@@ -40,15 +41,15 @@ export class ObsidianStorageAdapter implements IStorageAdapter<Stat> {
|
||||
}
|
||||
|
||||
async write(path: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||
return await this.app.vault.adapter.write(path, data, options);
|
||||
return await this.app.vault.adapter.write(path, data, toIntegerTimestamps(options));
|
||||
}
|
||||
|
||||
async writeBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
||||
return await this.app.vault.adapter.writeBinary(path, toArrayBuffer(data), options);
|
||||
return await this.app.vault.adapter.writeBinary(path, toArrayBuffer(data), toIntegerTimestamps(options));
|
||||
}
|
||||
|
||||
async append(path: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||
return await this.app.vault.adapter.append(path, data, options);
|
||||
return await this.app.vault.adapter.append(path, data, toIntegerTimestamps(options));
|
||||
}
|
||||
|
||||
list(basePath: string): Promise<{ files: string[]; folders: string[] }> {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { App } from "obsidian";
|
||||
import { ObsidianStorageAdapter } from "./ObsidianStorageAdapter";
|
||||
|
||||
describe("ObsidianStorageAdapter", () => {
|
||||
it("floors write-option timestamps before calling Obsidian storage methods", async () => {
|
||||
const write = vi.fn().mockResolvedValue(undefined);
|
||||
const writeBinary = vi.fn().mockResolvedValue(undefined);
|
||||
const append = vi.fn().mockResolvedValue(undefined);
|
||||
const app = {
|
||||
vault: {
|
||||
adapter: {
|
||||
write,
|
||||
writeBinary,
|
||||
append,
|
||||
},
|
||||
},
|
||||
} as unknown as App;
|
||||
const adapter = new ObsidianStorageAdapter(app);
|
||||
const options = { ctime: 1778511180024.462, mtime: 1778511180999.913 };
|
||||
const expectedOptions = { ctime: 1778511180024, mtime: 1778511180999 };
|
||||
|
||||
await adapter.write("note.md", "text", options);
|
||||
await adapter.writeBinary("image.bin", new ArrayBuffer(0), options);
|
||||
await adapter.append("log.md", "text", options);
|
||||
|
||||
expect(write).toHaveBeenCalledWith("note.md", "text", expectedOptions);
|
||||
expect(writeBinary).toHaveBeenCalledWith("image.bin", expect.any(ArrayBuffer), expectedOptions);
|
||||
expect(append).toHaveBeenCalledWith("log.md", "text", expectedOptions);
|
||||
expect(options).toEqual({ ctime: 1778511180024.462, mtime: 1778511180999.913 });
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/commo
|
||||
import type { IVaultAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
|
||||
import { toArrayBuffer } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
|
||||
import type { TFile, App, TFolder } from "obsidian";
|
||||
import { toIntegerTimestamps } from "./sanitizeWriteOptions";
|
||||
|
||||
/**
|
||||
* Vault adapter implementation for Obsidian
|
||||
@@ -23,19 +24,19 @@ export class ObsidianVaultAdapter implements IVaultAdapter<TFile, TFolder> {
|
||||
}
|
||||
|
||||
async modify(file: TFile, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||
return await this.app.vault.modify(file, data, options);
|
||||
return await this.app.vault.modify(file, data, toIntegerTimestamps(options));
|
||||
}
|
||||
|
||||
async modifyBinary(file: TFile, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
||||
return await this.app.vault.modifyBinary(file, toArrayBuffer(data), options);
|
||||
return await this.app.vault.modifyBinary(file, toArrayBuffer(data), toIntegerTimestamps(options));
|
||||
}
|
||||
|
||||
async create(path: string, data: string, options?: UXDataWriteOptions): Promise<TFile> {
|
||||
return await this.app.vault.create(path, data, options);
|
||||
return await this.app.vault.create(path, data, toIntegerTimestamps(options));
|
||||
}
|
||||
|
||||
async createBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<TFile> {
|
||||
return await this.app.vault.createBinary(path, toArrayBuffer(data), options);
|
||||
return await this.app.vault.createBinary(path, toArrayBuffer(data), toIntegerTimestamps(options));
|
||||
}
|
||||
|
||||
async rename(file: TFile, newPath: string): Promise<void> {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { App, TFile } from "obsidian";
|
||||
import { ObsidianVaultAdapter } from "./ObsidianVaultAdapter";
|
||||
|
||||
describe("ObsidianVaultAdapter.read", () => {
|
||||
describe("ObsidianVaultAdapter", () => {
|
||||
it("preserves a UTF-8 BOM so the content size matches the file stat", async () => {
|
||||
const path = "Transcripts/字幕.md";
|
||||
const contentWithoutBom = "字幕の検証行です。\n";
|
||||
@@ -34,4 +34,34 @@ describe("ObsidianVaultAdapter.read", () => {
|
||||
expect(adapterRead).toHaveBeenCalledWith(path);
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("floors write-option timestamps before calling Obsidian vault methods", async () => {
|
||||
const modify = vi.fn().mockResolvedValue(undefined);
|
||||
const modifyBinary = vi.fn().mockResolvedValue(undefined);
|
||||
const create = vi.fn().mockResolvedValue({});
|
||||
const createBinary = vi.fn().mockResolvedValue({});
|
||||
const app = {
|
||||
vault: {
|
||||
modify,
|
||||
modifyBinary,
|
||||
create,
|
||||
createBinary,
|
||||
},
|
||||
} as unknown as App;
|
||||
const file = { path: "note.md" } as TFile;
|
||||
const adapter = new ObsidianVaultAdapter(app);
|
||||
const options = { ctime: 1778511180024.462, mtime: 1778511180999.913 };
|
||||
const expectedOptions = { ctime: 1778511180024, mtime: 1778511180999 };
|
||||
|
||||
await adapter.modify(file, "text", options);
|
||||
await adapter.modifyBinary(file, new ArrayBuffer(0), options);
|
||||
await adapter.create("created.md", "text", options);
|
||||
await adapter.createBinary("created.bin", new ArrayBuffer(0), options);
|
||||
|
||||
expect(modify).toHaveBeenCalledWith(file, "text", expectedOptions);
|
||||
expect(modifyBinary).toHaveBeenCalledWith(file, expect.any(ArrayBuffer), expectedOptions);
|
||||
expect(create).toHaveBeenCalledWith("created.md", "text", expectedOptions);
|
||||
expect(createBinary).toHaveBeenCalledWith("created.bin", expect.any(ArrayBuffer), expectedOptions);
|
||||
expect(options).toEqual({ ctime: 1778511180024.462, mtime: 1778511180999.913 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
/**
|
||||
* Coerce the timestamp fields of a write-options object to integer milliseconds.
|
||||
*
|
||||
* On mobile, Obsidian forwards `mtime`/`ctime` to Capacitor's
|
||||
* Filesystem.setTimes, whose native binding casts the value to a Java `Long`.
|
||||
* A non-integer (float) timestamp makes that cast throw
|
||||
* `ClassCastException: Double cannot be cast to Long`, which crashes the app on
|
||||
* launch as soon as such a document is replicated in. Float timestamps can
|
||||
* enter the database from any client that stores `fs.Stats.mtimeMs` without
|
||||
* flooring. Flooring at the storage boundary guarantees every Obsidian write
|
||||
* carries an integer, so a float timestamp already present in the mesh cannot
|
||||
* brick the app.
|
||||
*
|
||||
* Returns a shallow copy so the caller's options object is not mutated; passes
|
||||
* `undefined` through unchanged.
|
||||
*/
|
||||
export function toIntegerTimestamps(options?: UXDataWriteOptions): UXDataWriteOptions | undefined {
|
||||
if (!options) return options;
|
||||
const sanitized: UXDataWriteOptions = { ...options };
|
||||
if (typeof sanitized.mtime === "number") sanitized.mtime = Math.floor(sanitized.mtime);
|
||||
if (typeof sanitized.ctime === "number") sanitized.ctime = Math.floor(sanitized.ctime);
|
||||
return sanitized;
|
||||
}
|
||||
+40
@@ -12,6 +12,46 @@ Earlier releases remain available in the 0.25 release history and the legacy rel
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 1.0.12
|
||||
|
||||
11th August, 2026
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Fixed
|
||||
|
||||
- One-shot CouchDB replication now closes its temporary remote database after each run and before retrying, preventing inactive PouchDB instances from accumulating during long-running periodic synchronisation (Commonlib PR #75). Thank you to @apple-ouyang for the contribution!
|
||||
- Start-up and recovery scans now keep failed database-to-Vault writes retryable instead of recording them as successful and later mistaking the still-missing file for a local deletion (Commonlib PR #106).
|
||||
- Files over the size limit or in conflict remain deliberately skipped, while actual write failures are reported to Fast Setup, CLI mirror, and daemon callers.
|
||||
|
||||
## 1.0.11
|
||||
|
||||
9th August, 2026
|
||||
|
||||
### Setup and compatibility
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Fast Setup now uses Standard Fetch when CouchDB's 'Use Internal API' setting is enabled, avoiding a streaming request path which Obsidian's buffered API cannot support (#1020).
|
||||
- Custom headers alone continue to use Fast Fetch when browser CORS permits them; Standard Fetch clears any obsolete Fast Fetch checkpoint after resetting the local database.
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Fractional file timestamps no longer cause affected mobile clients to crash after synchronisation (#1087, PR #1039). Thank you to @andrewleech for the contribution!
|
||||
- Timestamps are now normalised in the command-line tool and before Obsidian's native file-system writes.
|
||||
|
||||
## 1.0.10
|
||||
|
||||
9th August, 2026
|
||||
|
||||
### Setup and compatibility
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Fast Setup now sends configured CouchDB custom headers with every changes-feed request, allowing reverse proxies such as Cloudflare Access to authenticate initial setup in the same way as ordinary synchronisation ([Commonlib PR #82](https://github.com/vrtmrz/livesync-commonlib/pull/82)). Thank you to @nimula for the contribution!
|
||||
|
||||
## 1.0.9
|
||||
|
||||
8th August, 2026
|
||||
|
||||
+4
-1
@@ -21,5 +21,8 @@
|
||||
"1.0.6": "1.7.2",
|
||||
"1.0.7": "1.7.2",
|
||||
"1.0.8": "1.7.2",
|
||||
"1.0.9": "1.7.2"
|
||||
"1.0.9": "1.7.2",
|
||||
"1.0.10": "1.7.2",
|
||||
"1.0.11": "1.7.2",
|
||||
"1.0.12": "1.7.2"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user