mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-01 15:02:31 +00:00
Merge origin/main into time-bound Setup URI branch
This commit is contained in:
@@ -2,6 +2,64 @@
|
|||||||
|
|
||||||
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).
|
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.26
|
||||||
|
|
||||||
|
~~1.0.25~~ was cancelled because pre-release validation found that LiveSync could appear to finish synchronising even though Android had not written a received file to the Vault; the warning appeared only after restart.
|
||||||
|
|
||||||
|
6th September, 2026
|
||||||
|
|
||||||
|
### Synchronisation and storage
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- Files inside a folder are no longer silently removed from synchronisation when an external tool changes only the letter case of that folder while Obsidian is running. This prevents the stale deletion from reaching other devices or later removing the local file. Moving files into ignored or otherwise excluded locations retains the existing behaviour, and the folder-name case itself may still differ between devices. (#1168)
|
||||||
|
- A problem processing one file during ordinary start-up no longer prevents every other file from synchronising. LiveSync warns about the affected files and can retry them later; Fetch and Rebuild still stop if they cannot finish safely. (#1164)
|
||||||
|
- When LiveSync cannot finish preparing this device for synchronisation, it now says that synchronisation is unavailable and directs you to generate a report, instead of remaining at 'Not ready'. (#1164)
|
||||||
|
|
||||||
|
#### Improved
|
||||||
|
|
||||||
|
- When LiveSync cannot write a received file to the Vault, it now warns immediately instead of appearing to have synchronised it successfully. The generated report identifies the affected path, and a later scan can try it again.
|
||||||
|
|
||||||
|
### Conflict handling and recovery
|
||||||
|
|
||||||
|
#### Improved
|
||||||
|
|
||||||
|
- Conflict resolution dialogues now close when the same file is resolved elsewhere or when the plug-in unloads. Requests for different files are shown one at a time, while a newer request for the same file replaces the older one.
|
||||||
|
|
||||||
|
### Setup and compatibility
|
||||||
|
|
||||||
|
#### Improved
|
||||||
|
|
||||||
|
- Unconfigured Vaults now stay focused on setup instead of running Config Doctor or incomplete-document checks before they can be used. Returning a configured Vault to an unconfigured state also stops those checks until the requested restart. (#1161)
|
||||||
|
- When the active file contains a file or folder name longer than 255 UTF-8 bytes, LiveSync now explains that the path may not work on some Android and Linux file systems. It does not rename or reject the file. (#1164)
|
||||||
|
|
||||||
|
## 1.0.24
|
||||||
|
|
||||||
|
3rd September, 2026
|
||||||
|
|
||||||
|
### Interface and translation
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- The Setup Wizard now correctly explains that the existing-device path adds this device to an existing synchronisation (PR #1118). Thank you to @nikhilmaddirala for the contribution!
|
||||||
|
- Spanish translations now resolve the **Display language** placeholder, cover previously untranslated Setup Wizard and CouchDB text, translate user-facing Config Doctor values and confirmation controls, and use Spanish sentence case (PR #1129). Thank you to @zeedif for the contribution!
|
||||||
|
|
||||||
|
#### Improved
|
||||||
|
|
||||||
|
- The Setup Wizard now shows the passphrase and **Obfuscate Properties** controls only after E2EE is enabled, provides a password-visibility button, allows longer translated labels to wrap, and keeps the invitation link compact on desktop while preserving its mobile touch target (PR #1130). Thank you to @zeedif for the contribution!
|
||||||
|
|
||||||
|
### Synchronisation and storage
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- **Overwrite Server Data with This Device's Files** now keeps this device's synchronisation settings instead of reapplying settings from the remote database which is about to be replaced. Enabling E2EE before a rebuild therefore remains enabled and uploads encrypted data. (#1146)
|
||||||
|
|
||||||
|
### Command-line tool
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- The systemd installer now finds the repository root correctly, installs every generated bundle chunk and required production dependency, checks the installed command before activation, and reports success only when the service remains active.
|
||||||
|
|
||||||
## 1.0.23
|
## 1.0.23
|
||||||
|
|
||||||
2nd September, 2026
|
2nd September, 2026
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ path -> { revision, observedStorageMtime? }
|
|||||||
|
|
||||||
`revision` identifies the exact database revision most recently saved from or reflected in this device's Vault. It is the base for subsequent local edits, rather than a certificate that the current file still contains those bytes. `observedStorageMtime` is the raw local modification time of the saved snapshot or the file observed after reflection. It is not rounded, combined with another device's value, or used as proof of branch identity. No content hash is persisted.
|
`revision` identifies the exact database revision most recently saved from or reflected in this device's Vault. It is the base for subsequent local edits, rather than a certificate that the current file still contains those bytes. `observedStorageMtime` is the raw local modification time of the saved snapshot or the file observed after reflection. It is not rounded, combined with another device's value, or used as proof of branch identity. No content hash is persisted.
|
||||||
|
|
||||||
The record changes only after a successful database-to-Vault reflection or Vault-to-database write. Reading a file does not change it. The recorded revision remains authoritative even if the user edits the file to bytes which equal another branch; otherwise content equality could silently move the edit to a branch which was not displayed.
|
The record changes after a successful database-to-Vault reflection or Vault-to-database write. An equal-time scan may also fill a missing record while the current database revision uniquely matches the file's bytes and no conflict leaf exists. It rereads storage and the current database revision before recording; it does not change file bytes or create a database revision. Ordinary file reads do not change the record. An existing recorded revision remains authoritative even if the user edits the file to bytes which equal another branch; otherwise content equality could silently move the edit to a branch which was not displayed.
|
||||||
|
|
||||||
Saving and reflection for the same Metadata document run one at a time, including the final provenance update. An ordinary save holds one captured file body and its base until the database write completes. An edit made while that save is running belongs to the next operation; the save does not reread the file to prove that it remained unchanged. Different files retain their existing concurrency limits, and the handler acquires the lock before loading a file body from storage. Conflict checking runs after the lock is released so that an immediate resolution can safely call the file handler again. The host queues count document-lock waiters against their concurrency limits, so a burst for one document can temporarily delay unrelated files.
|
Saving and reflection for the same Metadata document run one at a time, including the final provenance update. An ordinary save holds one captured file body and its base until the database write completes. An edit made while that save is running belongs to the next operation; the save does not reread the file to prove that it remained unchanged. Different files retain their existing concurrency limits, and the handler acquires the lock before loading a file body from storage. Conflict checking runs after the lock is released so that an immediate resolution can safely call the file handler again. The host queues count document-lock waiters against their concurrency limits, so a burst for one document can temporarily delay unrelated files.
|
||||||
|
|
||||||
@@ -252,7 +252,7 @@ The first decision has already changed the ordinary revision tree. On restart, L
|
|||||||
|
|
||||||
### The device-local record is missing
|
### The device-local record is missing
|
||||||
|
|
||||||
A local-database reset removes revision provenance. When an ordinary save or incoming reflection examines the file, exactly one matching current non-deleted leaf can reconstruct the record. Multiple current matches prevent duplicate storage but leave branch identity unproved. A match only in past history is insufficient; differing current content is preserved as an independent branch. An unchanged-time scan alone does not guarantee that a record is created.
|
A local-database reset removes revision provenance. When an ordinary save or incoming reflection examines the file, exactly one matching current non-deleted leaf can reconstruct the record. An equal-time scan can fill a missing record only while the file bytes match the sole current non-deleted revision, the revision tree has no conflict, and the file and database remain unchanged during verification. A failed provenance read is not treated as a missing record. Multiple current matches prevent duplicate storage but leave branch identity unproved. A match only in past history is insufficient; differing current content is preserved as an independent branch. Once the local database has advanced beyond an unrecorded file, the scan cannot infer its earlier origin from historical byte equality.
|
||||||
|
|
||||||
If no current non-deleted leaf contains the file bytes, an ordinary save retains them as another independent branch. An unproven deletion leaves all existing branches intact. A cross-path rename stores the target but leaves every unproven source branch for review. The result can require an extra decision, but it does not discard data by guessing the winner.
|
If no current non-deleted leaf contains the file bytes, an ordinary save retains them as another independent branch. An unproven deletion leaves all existing branches intact. A cross-path rename stores the target but leaves every unproven source branch for review. The result can require an extra decision, but it does not discard data by guessing the winner.
|
||||||
|
|
||||||
@@ -279,7 +279,7 @@ Do not:
|
|||||||
|
|
||||||
LiveSync also exercises three and four independently editing devices through real CouchDB, using the installed Commonlib package and the CLI conflict-resolution command dispatcher. These tests check unchanged losing files before and after resolution, genuine edits on a losing branch, missing provenance, compacted bases, independent-root deduplication, and propagation of the selected result. See the [multiple-device regression procedure and coverage boundaries](../test/README.md#multiple-device-conflict-regression-tests).
|
LiveSync also exercises three and four independently editing devices through real CouchDB, using the installed Commonlib package and the CLI conflict-resolution command dispatcher. These tests check unchanged losing files before and after resolution, genuine edits on a losing branch, missing provenance, compacted bases, independent-root deduplication, and propagation of the selected result. See the [multiple-device regression procedure and coverage boundaries](../test/README.md#multiple-device-conflict-regression-tests).
|
||||||
|
|
||||||
Commonlib owns the real-PouchDB and injected-boundary tests for revision ancestry, content preservation, provenance, independent branches, and repeated file events. LiveSync owns persistent host composition and actual Obsidian restart coverage. The focused `test:e2e:obsidian:stale-file-restart` scenario advances the local DB while old Vault bytes remain, persists pending file events, and restarts the same isolated profile. It requires an unchanged recorded file to reflect the DB without a new revision, an unknown file to remain on an independent branch alongside the DB content, and repeated processing after provenance loss to leave those branches unchanged. It uses real local storage and startup processing; transport replication and mobile lifecycle coverage are separate.
|
Commonlib owns the real-PouchDB and injected-boundary tests for revision ancestry, content preservation, provenance, independent branches, and repeated file events. LiveSync owns persistent host composition and actual Obsidian restart coverage. The focused `test:e2e:obsidian:stale-file-restart` scenario advances the local DB while old Vault bytes remain, persists pending file events, and restarts the same isolated profile. It requires an unchanged recorded file to reflect the DB without a new revision, an unknown file to remain on an independent branch alongside the DB content, and repeated processing after provenance loss to leave those branches unchanged. It also removes the record for a file whose bytes still match the current database revision, checks that start-up restores the record without a new database revision, and then checks that a later incoming revision reflects without a conflict. It uses real local storage and startup processing; transport replication and mobile lifecycle coverage are separate.
|
||||||
|
|
||||||
LiveSync's optional real-Obsidian two-Vault checks have two scopes. `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true` resolves and edits a Markdown conflict, propagates it to a Vault which still displays the deleted losing content, and requires one current result to remain. `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` edits, deletes, case-renames, and cross-path-renames files while conflicts remain active; it verifies the parent revision of each resulting branch, replicates those exact trees, and confirms that the other conflict branches remain intact.
|
LiveSync's optional real-Obsidian two-Vault checks have two scopes. `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true` resolves and edits a Markdown conflict, propagates it to a Vault which still displays the deleted losing content, and requires one current result to remain. `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` edits, deletes, case-renames, and cross-path-renames files while conflicts remain active; it verifies the parent revision of each resulting branch, replicates those exact trees, and confirms that the other conflict branches remain intact.
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ Do not switch to P2P or reset the database as the first response. Check:
|
|||||||
|
|
||||||
If the remote is healthy but one device's local database is not, use [Reset Synchronisation on This Device](recovery.md#reset-synchronisation-on-this-device) only after backing up unsynchronised local files.
|
If the remote is healthy but one device's local database is not, use [Reset Synchronisation on This Device](recovery.md#reset-synchronisation-on-this-device) only after backing up unsynchronised local files.
|
||||||
|
|
||||||
|
## An unchanged file appears as a conflict after restart
|
||||||
|
|
||||||
|
If the log says `Preserved unsynchronised local changes as a conflict`, keep both versions available until you have checked their contents. The message means that LiveSync could not prove that the Vault file was unchanged; it does not establish that you edited the file. Use **Inspect conflicts and file/database differences** in Hatch to review the current branches before selecting a version.
|
||||||
|
|
||||||
|
At start-up, LiveSync can restore a missing device-local revision record when the file still exactly matches the current local database revision. If a newer revision has already arrived, that match may no longer exist. LiveSync then preserves the old file for review because it cannot distinguish an unchanged copy from an intentional edit back to older content. A new release cannot resolve a conflict which was already created merely by recognising a historical content match. If the problem recurs, include the first relevant log entries, plug-in versions on both devices, and a redacted full report with the issue steps.
|
||||||
|
|
||||||
## Synchronisation is paused for compatibility review
|
## Synchronisation is paused for compatibility review
|
||||||
|
|
||||||
A compatibility review is separate from the Change Log. It can appear after an internal database or settings-format change, or when a configured Vault is copied, restored, or opened in a new Obsidian profile without its device-local acknowledgement.
|
A compatibility review is separate from the Change Log. It can appear after an internal database or settings-format change, or when a configured Vault is copied, restored, or opened in a new Obsidian profile without its device-local acknowledgement.
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "obsidian-livesync",
|
"id": "obsidian-livesync",
|
||||||
"name": "Self-hosted LiveSync",
|
"name": "Self-hosted LiveSync",
|
||||||
"version": "1.0.30",
|
"version": "1.0.32",
|
||||||
"minAppVersion": "1.7.2",
|
"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.",
|
"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",
|
"author": "vorotamoroz",
|
||||||
|
|||||||
Generated
+9
-9
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "obsidian-livesync",
|
"name": "obsidian-livesync",
|
||||||
"version": "1.0.30",
|
"version": "1.0.32",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "obsidian-livesync",
|
"name": "obsidian-livesync",
|
||||||
"version": "1.0.30",
|
"version": "1.0.32",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"src/apps/cli",
|
"src/apps/cli",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"@smithy/types": "^4.14.3",
|
"@smithy/types": "^4.14.3",
|
||||||
"@smithy/util-retry": "^4.4.5",
|
"@smithy/util-retry": "^4.4.5",
|
||||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||||
"@vrtmrz/livesync-commonlib": "0.1.27",
|
"@vrtmrz/livesync-commonlib": "0.1.29",
|
||||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||||
"@vrtmrz/ui-interactions": "0.1.2",
|
"@vrtmrz/ui-interactions": "0.1.2",
|
||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
@@ -4567,9 +4567,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||||
"version": "0.1.27",
|
"version": "0.1.29",
|
||||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.27.tgz",
|
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.29.tgz",
|
||||||
"integrity": "sha512-n/AIqXPGN9Z7zmq0QSKnlE5uY5hTZgXW6VM6NJERR0/ggx3TTv1b3WsSbiLpCeU7Mu6KbPqC2BSjr4jCGvSdCA==",
|
"integrity": "sha512-PeBUUQNSuyS+ISPIvwK3rzaritHi0XDfNlFMOqaRpIbxGAWbWK0YDtE1Htbde3r8Lr3ZdTNjpY+Nmr//OxzgTA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.808.0",
|
"@aws-sdk/client-s3": "^3.808.0",
|
||||||
@@ -12665,7 +12665,7 @@
|
|||||||
},
|
},
|
||||||
"src/apps/cli": {
|
"src/apps/cli": {
|
||||||
"name": "self-hosted-livesync-cli",
|
"name": "self-hosted-livesync-cli",
|
||||||
"version": "1.0.30-cli",
|
"version": "1.0.32-cli",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^4.0.0",
|
"chokidar": "^4.0.0",
|
||||||
"minimatch": "^10.2.5",
|
"minimatch": "^10.2.5",
|
||||||
@@ -12690,7 +12690,7 @@
|
|||||||
},
|
},
|
||||||
"src/apps/webapp": {
|
"src/apps/webapp": {
|
||||||
"name": "livesync-webapp",
|
"name": "livesync-webapp",
|
||||||
"version": "1.0.30-webapp",
|
"version": "1.0.32-webapp",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"octagonal-wheels": "^0.1.54"
|
"octagonal-wheels": "^0.1.54"
|
||||||
},
|
},
|
||||||
@@ -12702,7 +12702,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/apps/webpeer": {
|
"src/apps/webpeer": {
|
||||||
"version": "1.0.30-webpeer",
|
"version": "1.0.32-webpeer",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"octagonal-wheels": "^0.1.54"
|
"octagonal-wheels": "^0.1.54"
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsidian-livesync",
|
"name": "obsidian-livesync",
|
||||||
"version": "1.0.30",
|
"version": "1.0.32",
|
||||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
"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",
|
"main": "main.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -183,7 +183,7 @@
|
|||||||
"@smithy/types": "^4.14.3",
|
"@smithy/types": "^4.14.3",
|
||||||
"@smithy/util-retry": "^4.4.5",
|
"@smithy/util-retry": "^4.4.5",
|
||||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||||
"@vrtmrz/livesync-commonlib": "0.1.27",
|
"@vrtmrz/livesync-commonlib": "0.1.29",
|
||||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||||
"@vrtmrz/ui-interactions": "0.1.2",
|
"@vrtmrz/ui-interactions": "0.1.2",
|
||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "self-hosted-livesync-cli",
|
"name": "self-hosted-livesync-cli",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.30-cli",
|
"version": "1.0.32-cli",
|
||||||
"main": "dist/index.cjs",
|
"main": "dist/index.cjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "livesync-webapp",
|
"name": "livesync-webapp",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.30-webapp",
|
"version": "1.0.32-webapp",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "webpeer",
|
"name": "webpeer",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.30-webpeer",
|
"version": "1.0.32-webpeer",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
normalizePath: vi.fn((path: string) => `normalised(${path})`),
|
||||||
|
path2idBase: vi.fn(async (path: string) => path),
|
||||||
|
id2pathBase: vi.fn((path: string) => path),
|
||||||
|
expandFilePathPrefix: vi.fn((path: string): [string, string] => {
|
||||||
|
if (path.startsWith("i:")) return ["i:", path.substring(2)];
|
||||||
|
return ["", path];
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/deps.ts", () => ({
|
||||||
|
normalizePath: mocks.normalizePath,
|
||||||
|
Platform: {},
|
||||||
|
requestUrl: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@vrtmrz/livesync-commonlib/compat/string_and_binary/path", () => ({
|
||||||
|
path2id_base: mocks.path2idBase,
|
||||||
|
id2path_base: mocks.id2pathBase,
|
||||||
|
expandFilePathPrefix: mocks.expandFilePathPrefix,
|
||||||
|
isValidFilenameInLinux: vi.fn(),
|
||||||
|
isValidFilenameInDarwin: vi.fn(),
|
||||||
|
isValidFilenameInWidows: vi.fn(),
|
||||||
|
isValidFilenameInAndroid: vi.fn(),
|
||||||
|
stripAllPrefixes: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("path ID normalisation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["Folder/Note.md", "", "Folder/Note.md"],
|
||||||
|
["Folder/Poem: Example.md", "", "Folder/Poem: Example.md"],
|
||||||
|
["Folder/Poem: Example: Final Draft.md", "", "Folder/Poem: Example: Final Draft.md"],
|
||||||
|
["i:Folder/Poem: Example.md", "i:", "Folder/Poem: Example.md"],
|
||||||
|
])("normalises the complete path body for %s", async (filename, prefix, body) => {
|
||||||
|
const { path2id } = await import("./utils.ts");
|
||||||
|
|
||||||
|
const result = await path2id(filename as never, false, false);
|
||||||
|
|
||||||
|
expect(mocks.normalizePath).toHaveBeenCalledWith(body);
|
||||||
|
expect(mocks.path2idBase).toHaveBeenCalledWith(`${prefix}normalised(${body})`, false, false);
|
||||||
|
expect(result).toBe(`${prefix}normalised(${body})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["Folder/Note.md", "", "Folder/Note.md"],
|
||||||
|
["Folder/Poem: Example.md", "", "Folder/Poem: Example.md"],
|
||||||
|
["Folder/Poem: Example: Final Draft.md", "", "Folder/Poem: Example: Final Draft.md"],
|
||||||
|
["i:Folder/Poem: Example.md", "i:", "Folder/Poem: Example.md"],
|
||||||
|
])("preserves the path namespace while normalising %s", async (filename, prefix, body) => {
|
||||||
|
mocks.id2pathBase.mockReturnValue(filename);
|
||||||
|
const { id2path } = await import("./utils.ts");
|
||||||
|
|
||||||
|
const result = id2path(filename as never);
|
||||||
|
|
||||||
|
expect(mocks.normalizePath).toHaveBeenCalledWith(body);
|
||||||
|
expect(result).toBe(`${prefix}normalised(${body})`);
|
||||||
|
});
|
||||||
|
});
|
||||||
+5
-8
@@ -7,6 +7,7 @@ import {
|
|||||||
isValidFilenameInWidows,
|
isValidFilenameInWidows,
|
||||||
isValidFilenameInAndroid,
|
isValidFilenameInAndroid,
|
||||||
stripAllPrefixes,
|
stripAllPrefixes,
|
||||||
|
expandFilePathPrefix,
|
||||||
} from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
} from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||||
|
|
||||||
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||||
@@ -41,22 +42,18 @@ export async function path2id(
|
|||||||
obfuscatePassphrase: string | false,
|
obfuscatePassphrase: string | false,
|
||||||
caseInsensitive: boolean
|
caseInsensitive: boolean
|
||||||
): Promise<DocumentID> {
|
): Promise<DocumentID> {
|
||||||
const temp = filename.split(":");
|
const [prefix, path] = expandFilePathPrefix(filename);
|
||||||
const path = temp.pop();
|
|
||||||
const normalizedPath = normalizePath(path as FilePath);
|
const normalizedPath = normalizePath(path as FilePath);
|
||||||
temp.push(normalizedPath);
|
const fixedPath = `${prefix}${normalizedPath}` as FilePathWithPrefix;
|
||||||
const fixedPath = temp.join(":") as FilePathWithPrefix;
|
|
||||||
|
|
||||||
const out = await path2id_base(fixedPath, obfuscatePassphrase, caseInsensitive);
|
const out = await path2id_base(fixedPath, obfuscatePassphrase, caseInsensitive);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
export function id2path(id: DocumentID, entry?: EntryHasPath): FilePathWithPrefix {
|
export function id2path(id: DocumentID, entry?: EntryHasPath): FilePathWithPrefix {
|
||||||
const filename = id2path_base(id, entry);
|
const filename = id2path_base(id, entry);
|
||||||
const temp = filename.split(":");
|
const [prefix, path] = expandFilePathPrefix(filename);
|
||||||
const path = temp.pop();
|
|
||||||
const normalizedPath = normalizePath(path as FilePath);
|
const normalizedPath = normalizePath(path as FilePath);
|
||||||
temp.push(normalizedPath);
|
const fixedPath = `${prefix}${normalizedPath}` as FilePathWithPrefix;
|
||||||
const fixedPath = temp.join(":") as FilePathWithPrefix;
|
|
||||||
return fixedPath;
|
return fixedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,20 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
|
|||||||
log: LogFunction,
|
log: LogFunction,
|
||||||
cleanupFlag: () => Promise<void>
|
cleanupFlag: () => Promise<void>
|
||||||
): Promise<boolean | undefined> {
|
): Promise<boolean | undefined> {
|
||||||
|
if (host.services.setting.currentSettings().maxMTimeForReflectEvents > 0) {
|
||||||
|
// Simple Fetch reconciles storage with the local database after fetching, past the check which
|
||||||
|
// refuses that scan in remediation mode. Skipping only the scan would restore nothing from most
|
||||||
|
// remotes: reflection of received documents stays suspended while Simple Fetch fetches, so Object
|
||||||
|
// Storage and P2P remotes discard them, and CouchDB Fast Fetch writes them straight into the
|
||||||
|
// database. The detailed flow at least states the restriction and offers to clear it before
|
||||||
|
// rebuilding, instead of quietly reconciling past it.
|
||||||
|
log(
|
||||||
|
"Remediation mode is active, so the detailed fetch flow is used instead of Simple Fetch.",
|
||||||
|
LOG_LEVEL_NOTICE
|
||||||
|
);
|
||||||
|
clearRememberedSimpleFetchMode(host);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const result = await askSimpleFetchMode(host);
|
const result = await askSimpleFetchMode(host);
|
||||||
if (result === "cancelled") {
|
if (result === "cancelled") {
|
||||||
log("Fetch cancelled by user.", LOG_LEVEL_NOTICE);
|
log("Fetch cancelled by user.", LOG_LEVEL_NOTICE);
|
||||||
|
|||||||
@@ -771,6 +771,24 @@ describe("Red Flag Feature", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("askAndPerformFastSetupOnScheduledFetchAll", () => {
|
describe("askAndPerformFastSetupOnScheduledFetchAll", () => {
|
||||||
|
it("uses the detailed flow instead of Simple Fetch while remediation mode is active", async () => {
|
||||||
|
const host = createHostMock();
|
||||||
|
const log = createLoggerMock();
|
||||||
|
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
Object.assign(host.mocks.setting.settings, {
|
||||||
|
maxMTimeForReflectEvents: Date.parse("2026-09-01T00:00:00Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag)).resolves.toBe(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(host.mocks.ui.confirm.confirmWithMessage).not.toHaveBeenCalled();
|
||||||
|
expect(host.mocks.setting.deleteSmallConfig).toHaveBeenCalledWith("simple-fetch-mode");
|
||||||
|
expect(cleanupFlag).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("releases both reflection suspensions after Fast Setup succeeds", async () => {
|
it("releases both reflection suspensions after Fast Setup succeeds", async () => {
|
||||||
const host = createHostMock();
|
const host = createHostMock();
|
||||||
const log = createLoggerMock();
|
const log = createLoggerMock();
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ type ReplicateResultProcessorSettings = Pick<
|
|||||||
>;
|
>;
|
||||||
type ReplicateResultProcessorServices = Pick<
|
type ReplicateResultProcessorServices = Pick<
|
||||||
LiveSyncBaseCore["services"],
|
LiveSyncBaseCore["services"],
|
||||||
"appLifecycle" | "path" | "replication" | "vault"
|
"appLifecycle" | "database" | "path" | "replication" | "vault"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,10 +115,27 @@ export class ReplicateResultProcessor {
|
|||||||
// If true, the processing queue processor bails the loop.
|
// If true, the processing queue processor bails the loop.
|
||||||
private _suspended: boolean = false;
|
private _suspended: boolean = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the application accepts replicated documents being applied.
|
||||||
|
*
|
||||||
|
* Remediation mode refuses the reconciliation scan which readiness depends upon, so the
|
||||||
|
* application stays unready for as long as the modification-time limit is configured.
|
||||||
|
* Applying the received documents is what that mode exists for, and `parseDocumentChange` keeps
|
||||||
|
* each one within the limit, so readiness is not required while the mode is active.
|
||||||
|
*/
|
||||||
|
private get acceptsResultApplication() {
|
||||||
|
if (this.services.appLifecycle.isReady()) return true;
|
||||||
|
if (this.context.currentSettings().maxMTimeForReflectEvents <= 0) return false;
|
||||||
|
// A fetch resets the local database, and a remote which reflects while fetching leaves this
|
||||||
|
// processor unsuspended throughout. A document applied then cannot gather its chunks and is
|
||||||
|
// dropped, so the database itself must still be usable.
|
||||||
|
return this.services.database.isDatabaseReady();
|
||||||
|
}
|
||||||
|
|
||||||
public get isSuspended() {
|
public get isSuspended() {
|
||||||
return (
|
return (
|
||||||
this._suspended ||
|
this._suspended ||
|
||||||
!this.services.appLifecycle.isReady() ||
|
!this.acceptsResultApplication ||
|
||||||
this.context.currentSettings().suspendParseReplicationResult ||
|
this.context.currentSettings().suspendParseReplicationResult ||
|
||||||
this.services.appLifecycle.isSuspended()
|
this.services.appLifecycle.isSuspended()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { VER, type EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import { VER, type EntryDoc, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
import {
|
||||||
|
isValidFilenameInAndroid,
|
||||||
|
isValidFilenameInWidows,
|
||||||
|
} from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||||
import {
|
import {
|
||||||
defaultLogger,
|
defaultLogger,
|
||||||
LOG_LEVEL_DEBUG,
|
LOG_LEVEL_DEBUG,
|
||||||
@@ -28,6 +32,9 @@ function note(id: string): PouchDB.Core.ExistingDocument<EntryDoc> {
|
|||||||
|
|
||||||
type SetupOptions = {
|
type SetupOptions = {
|
||||||
applicationReady?: boolean;
|
applicationReady?: boolean;
|
||||||
|
databaseReady?: boolean;
|
||||||
|
maxMTimeForReflectEvents?: number;
|
||||||
|
isValidPath?: (path: string) => boolean;
|
||||||
processSynchroniseResult?: (entry: unknown) => Promise<boolean>;
|
processSynchroniseResult?: (entry: unknown) => Promise<boolean>;
|
||||||
setSnapshot?: (key: string, value: unknown) => Promise<unknown>;
|
setSnapshot?: (key: string, value: unknown) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
@@ -38,9 +45,12 @@ function setup(options: SetupOptions = {}) {
|
|||||||
const runBoundedLocalApplicationActivity = vi.fn(async (task: () => Promise<void>) => await task());
|
const runBoundedLocalApplicationActivity = vi.fn(async (task: () => Promise<void>) => await task());
|
||||||
const onCloseActiveReplication = vi.fn(async () => true);
|
const onCloseActiveReplication = vi.fn(async () => true);
|
||||||
const isReady = vi.fn(() => options.applicationReady ?? true);
|
const isReady = vi.fn(() => options.applicationReady ?? true);
|
||||||
|
const isValidPath = vi.fn(options.isValidPath ?? (() => true));
|
||||||
|
const getDBEntryFromMeta = vi.fn(async (entry: object) => ({ ...entry, data: "x" }));
|
||||||
const core = {
|
const core = {
|
||||||
services: {
|
services: {
|
||||||
appLifecycle: { isReady, isSuspended: () => false },
|
appLifecycle: { isReady, isSuspended: () => false },
|
||||||
|
database: { isDatabaseReady: () => options.databaseReady ?? true },
|
||||||
path: { getPath: (entry: { path: string }) => entry.path },
|
path: { getPath: (entry: { path: string }) => entry.path },
|
||||||
replication: {
|
replication: {
|
||||||
databaseQueueCount: reactiveSource(0),
|
databaseQueueCount: reactiveSource(0),
|
||||||
@@ -54,17 +64,20 @@ function setup(options: SetupOptions = {}) {
|
|||||||
vault: {
|
vault: {
|
||||||
isTargetFile: vi.fn(async () => true),
|
isTargetFile: vi.fn(async () => true),
|
||||||
isFileSizeTooLarge: vi.fn(() => false),
|
isFileSizeTooLarge: vi.fn(() => false),
|
||||||
isValidPath: vi.fn(() => true),
|
isValidPath,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
kvDB: { set: setSnapshot },
|
kvDB: { set: setSnapshot },
|
||||||
localDatabase: {
|
localDatabase: {
|
||||||
getRaw: vi.fn(async (id: string) => ({ _id: id, _rev: "1-test" })),
|
getRaw: vi.fn(async (id: string) => ({ _id: id, _rev: "1-test" })),
|
||||||
getDBEntryFromMeta: vi.fn(async (entry: object) => ({ ...entry, data: "x" })),
|
getDBEntryFromMeta,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const processor = new ReplicateResultProcessor({
|
const processor = new ReplicateResultProcessor({
|
||||||
currentSettings: () => ({ maxMTimeForReflectEvents: 0, suspendParseReplicationResult: false }),
|
currentSettings: () => ({
|
||||||
|
maxMTimeForReflectEvents: options.maxMTimeForReflectEvents ?? 0,
|
||||||
|
suspendParseReplicationResult: false,
|
||||||
|
}),
|
||||||
getKeyValueDB: () => core.kvDB,
|
getKeyValueDB: () => core.kvDB,
|
||||||
getLocalDatabase: () => core.localDatabase,
|
getLocalDatabase: () => core.localDatabase,
|
||||||
requestActiveReplicatorRetirement: () => {
|
requestActiveReplicatorRetirement: () => {
|
||||||
@@ -74,7 +87,9 @@ function setup(options: SetupOptions = {}) {
|
|||||||
services: core.services,
|
services: core.services,
|
||||||
} as never);
|
} as never);
|
||||||
return {
|
return {
|
||||||
|
getDBEntryFromMeta,
|
||||||
isReady,
|
isReady,
|
||||||
|
isValidPath,
|
||||||
onCloseActiveReplication,
|
onCloseActiveReplication,
|
||||||
processor,
|
processor,
|
||||||
processSynchroniseResult,
|
processSynchroniseResult,
|
||||||
@@ -83,6 +98,24 @@ function setup(options: SetupOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("ReplicateResultProcessor", () => {
|
describe("ReplicateResultProcessor", () => {
|
||||||
|
it.each([
|
||||||
|
["Windows", isValidFilenameInWidows],
|
||||||
|
["Android", isValidFilenameInAndroid],
|
||||||
|
])("does not reflect a replicated colon path into the %s Vault", async (_platform, validatePath) => {
|
||||||
|
const path = "Folder/Poem: Example.md" as FilePathWithPrefix;
|
||||||
|
const document = { ...note("colon-path"), path };
|
||||||
|
const { getDBEntryFromMeta, isValidPath, processor, processSynchroniseResult } = setup({
|
||||||
|
isValidPath: validatePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
processor.enqueueAll([document]);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(isValidPath).toHaveBeenCalledWith(path));
|
||||||
|
await vi.waitFor(() => expect(processor["_processingChanges"]).toHaveLength(0));
|
||||||
|
expect(getDBEntryFromMeta).toHaveBeenCalledWith(expect.objectContaining({ path }), false, true);
|
||||||
|
expect(processSynchroniseResult).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("resumes another document after in-flight updates to one document fill the application slots", async () => {
|
it("resumes another document after in-flight updates to one document fill the application slots", async () => {
|
||||||
const hotGate = promiseWithResolvers<boolean>();
|
const hotGate = promiseWithResolvers<boolean>();
|
||||||
const { processor, processSynchroniseResult } = setup({
|
const { processor, processSynchroniseResult } = setup({
|
||||||
@@ -124,6 +157,45 @@ describe("ReplicateResultProcessor", () => {
|
|||||||
expect(isReady).toHaveBeenCalledOnce();
|
expect(isReady).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies results in remediation mode, which never reports readiness", () => {
|
||||||
|
const { processor } = setup({
|
||||||
|
applicationReady: false,
|
||||||
|
maxMTimeForReflectEvents: Date.parse("2026-09-01T00:00:00Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(processor.isSuspended).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds results in remediation mode while the local database is being rebuilt", () => {
|
||||||
|
const { processor } = setup({
|
||||||
|
applicationReady: false,
|
||||||
|
databaseReady: false,
|
||||||
|
maxMTimeForReflectEvents: Date.parse("2026-09-01T00:00:00Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(processor.isSuspended).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still skips a document modified after the limit while the application is unready", async () => {
|
||||||
|
const maxMTimeForReflectEvents = Date.parse("2026-09-01T00:00:00Z");
|
||||||
|
const { processor, processSynchroniseResult } = setup({
|
||||||
|
applicationReady: false,
|
||||||
|
maxMTimeForReflectEvents,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tooRecent = {
|
||||||
|
...note("too-recent"),
|
||||||
|
mtime: maxMTimeForReflectEvents + 1,
|
||||||
|
} as PouchDB.Core.ExistingDocument<EntryDoc>;
|
||||||
|
processor.enqueueAll([tooRecent]);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(processor["_queuedChanges"]).toHaveLength(0);
|
||||||
|
expect(processor["_processingChanges"]).toHaveLength(0);
|
||||||
|
});
|
||||||
|
expect(processSynchroniseResult).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("retires active ownership when a newer remote version is observed", async () => {
|
it("retires active ownership when a newer remote version is observed", async () => {
|
||||||
const { onCloseActiveReplication, processor } = setup();
|
const { onCloseActiveReplication, processor } = setup();
|
||||||
const versionInfo = {
|
const versionInfo = {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export function useReplicationFeature<TContext extends ServiceContext, TCommands
|
|||||||
: await task(),
|
: await task(),
|
||||||
services: {
|
services: {
|
||||||
appLifecycle: services.appLifecycle,
|
appLifecycle: services.appLifecycle,
|
||||||
|
database: services.database,
|
||||||
path: services.path,
|
path: services.path,
|
||||||
replication: services.replication,
|
replication: services.replication,
|
||||||
vault: services.vault,
|
vault: services.vault,
|
||||||
|
|||||||
+1
-1
@@ -122,4 +122,4 @@ Each simulated device owns a separate real PouchDB database, `LiveSyncLocalDB` m
|
|||||||
|
|
||||||
This is a service integration test, not a CLI subprocess or Obsidian runtime test. The file and provenance stores are in-memory fixtures; automatic conflict callbacks are observed without running interactive or automatic merge policies. PouchDB revision creation, chunk storage and retrieval, local compaction, CouchDB replication, the CLI command dispatcher, and its resolution operations are real.
|
This is a service integration test, not a CLI subprocess or Obsidian runtime test. The file and provenance stores are in-memory fixtures; automatic conflict callbacks are observed without running interactive or automatic merge policies. PouchDB revision creation, chunk storage and retrieval, local compaction, CouchDB replication, the CLI command dispatcher, and its resolution operations are real.
|
||||||
|
|
||||||
Use the CLI and real-Obsidian procedures linked above for argument parsing, persistent host stores, file watchers, and dialogues. In particular, the real-Obsidian `stale-file-restart` scenario exercises persisted pending events and restart, and `folder-batch` exercises bulk Vault rename and deletion. These scenarios do not simulate a mobile operating system suspending the application.
|
Use the CLI and real-Obsidian procedures linked above for argument parsing, persistent host stores, file watchers, and dialogues. In particular, the real-Obsidian `stale-file-restart` scenario exercises persisted pending events and restart. It checks that start-up records an unchanged file whose provenance is missing, then accepts a newer database revision without a conflict, while an unknown historical file remains protected. The `folder-batch` scenario exercises bulk Vault rename and deletion. These scenarios do not simulate a mobile operating system suspending the application.
|
||||||
|
|||||||
@@ -82,9 +82,9 @@ npm run test:e2e:obsidian:focused -- security-seed-reconnect
|
|||||||
|
|
||||||
The wrapper accepts only maintained real-Obsidian scenario names; run it with `--help` for the current list. It deliberately does not manage CouchDB, Object Storage, or the P2P signalling relay. Start the required fixture first, or use the complete service-managed suite.
|
The wrapper accepts only maintained real-Obsidian scenario names; run it with `--help` for the current list. It deliberately does not manage CouchDB, Object Storage, or the P2P signalling relay. Start the required fixture first, or use the complete service-managed suite.
|
||||||
|
|
||||||
`folder-batch` needs no remote service. It creates 24 notes in nested folders, renames and deletes the parent through the Obsidian Vault API, and checks descendant events, content, Chunks, deletion markers, and provenance. A note outside the parent must remain writable.
|
`folder-batch` needs no remote service. It creates 24 notes and imports three notes with colons in their names into nested folders, reflects the database content into the existing files, then renames and deletes the parent through the Obsidian Vault API. It checks exact paths, descendant events, content, Chunks, deletion markers, provenance, and the absence of unexpected files. A note outside the parent must remain writable. The colon fixtures use the adapter to represent externally created files because Obsidian's Vault creation API rejects those names. The scenario also seeds a database-only colon-named note and verifies that Obsidian's refusal to create it preserves its Metadata without writing a differently named Vault file. It does not establish successful restoration of that absent note or behaviour on other operating systems.
|
||||||
|
|
||||||
`stale-file-restart` needs no remote service. It advances the local database while old Vault bytes remain, persists pending storage events, and restarts the same isolated Vault and profile. It checks that an unchanged file with exact provenance receives the newer database content without creating a revision, that unknown-origin content is preserved on a fresh independent branch, and that losing provenance and processing the file again does not duplicate or automatically merge that branch. The database advance and pending snapshot are controlled fixtures; startup processing, persistence, file reflection, and conflict checking run in real Obsidian. The scenario does not simulate a mobile operating system suspending the application.
|
`stale-file-restart` needs no remote service. It advances the local database while old Vault bytes remain, persists pending storage events, and restarts the same isolated Vault and profile. It checks that an unchanged file with exact provenance receives the newer database content without creating a revision, that unknown-origin content is preserved on a fresh independent branch, and that losing provenance and processing the file again does not duplicate or automatically merge that branch. A third file begins with no provenance while its bytes still match the current database revision; start-up must record that revision without creating a new one, and a later incoming revision must reflect without a conflict. The database advances and pending snapshot are controlled fixtures; start-up processing, persistence, file reflection, and conflict checking run in real Obsidian. The scenario does not simulate a mobile operating system suspending the application.
|
||||||
|
|
||||||
The principal entry points are:
|
The principal entry points are:
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,17 @@ process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000";
|
|||||||
const originalRoot = "batch/original";
|
const originalRoot = "batch/original";
|
||||||
const renamedRoot = "batch/renamed";
|
const renamedRoot = "batch/renamed";
|
||||||
const outsidePath = "batch/outside.md";
|
const outsidePath = "batch/outside.md";
|
||||||
|
const missingColonPath = "batch/incoming/Poem: Example.md";
|
||||||
const folders = ["alpha", "alpha/deep", "beta"];
|
const folders = ["alpha", "alpha/deep", "beta"];
|
||||||
const notes = Array.from({ length: 24 }, (_, index) => ({
|
const notes = Array.from({ length: 24 }, (_, index) => ({
|
||||||
relativePath: `${folders[index % folders.length]}/note-${index}.md`,
|
relativePath: `${folders[index % folders.length]}/note-${index}.md`,
|
||||||
body: `# Descendant ${index}\n\nThis body must survive a parent folder rename.\n`,
|
body: `# Descendant ${index}\n\nThis body must survive a parent folder rename.\n`,
|
||||||
}));
|
}));
|
||||||
|
notes.push(
|
||||||
|
{ relativePath: "alpha/Poem: Example.md", body: "First poem\n" },
|
||||||
|
{ relativePath: "beta/Poem: Example.md", body: "Second poem\n" },
|
||||||
|
{ relativePath: "alpha/deep/Poem: Part: Example.md", body: "Poem with multiple colons\n" }
|
||||||
|
);
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const binary = requireObsidianBinary();
|
const binary = requireObsidianBinary();
|
||||||
@@ -45,7 +51,7 @@ async function main(): Promise<void> {
|
|||||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||||
});
|
});
|
||||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||||
const result = await evalObsidianJson<{ descendants: number; renamed: number; deleted: number }>(
|
const result = await evalObsidianJson<{ descendants: number; renamed: number; deleted: number; missingRejected: boolean }>(
|
||||||
cliBinary,
|
cliBinary,
|
||||||
`(async()=>{
|
`(async()=>{
|
||||||
const core=app.plugins.plugins['obsidian-livesync'].core;
|
const core=app.plugins.plugins['obsidian-livesync'].core;
|
||||||
@@ -54,6 +60,7 @@ async function main(): Promise<void> {
|
|||||||
const originalRoot=${JSON.stringify(originalRoot)};
|
const originalRoot=${JSON.stringify(originalRoot)};
|
||||||
const renamedRoot=${JSON.stringify(renamedRoot)};
|
const renamedRoot=${JSON.stringify(renamedRoot)};
|
||||||
const outsidePath=${JSON.stringify(outsidePath)};
|
const outsidePath=${JSON.stringify(outsidePath)};
|
||||||
|
const missingColonPath=${JSON.stringify(missingColonPath)};
|
||||||
const renamed=new Set(), deleted=new Set();
|
const renamed=new Set(), deleted=new Set();
|
||||||
const refs=[
|
const refs=[
|
||||||
app.vault.on('rename',(file,oldPath)=>{
|
app.vault.on('rename',(file,oldPath)=>{
|
||||||
@@ -112,11 +119,23 @@ async function main(): Promise<void> {
|
|||||||
await app.vault.createFolder(originalRoot);
|
await app.vault.createFolder(originalRoot);
|
||||||
for(const folder of ${JSON.stringify(folders)})
|
for(const folder of ${JSON.stringify(folders)})
|
||||||
await app.vault.createFolder(originalRoot+'/'+folder);
|
await app.vault.createFolder(originalRoot+'/'+folder);
|
||||||
await Promise.all(notes.map(note=>app.vault.create(originalRoot+'/'+note.relativePath,note.body)));
|
// Obsidian indexes imported colon names but rejects them in Vault.create.
|
||||||
|
await Promise.all(notes.map(note=>note.relativePath.includes(':')
|
||||||
|
? app.vault.adapter.write(originalRoot+'/'+note.relativePath,note.body)
|
||||||
|
: app.vault.create(originalRoot+'/'+note.relativePath,note.body)));
|
||||||
await app.vault.create(outsidePath,'Outside note');
|
await app.vault.create(outsidePath,'Outside note');
|
||||||
await waitFor('Initial batch',async()=>[
|
await waitFor('Initial batch',async()=>[
|
||||||
...await liveBatch(originalRoot), ...await liveErrors(outsidePath,'Outside note'),
|
...await liveBatch(originalRoot), ...await liveErrors(outsidePath,'Outside note'),
|
||||||
]);
|
]);
|
||||||
|
for(const note of notes){
|
||||||
|
const path=originalRoot+'/'+note.relativePath;
|
||||||
|
if(!await core.serviceModules.fileHandler.dbToStorage(await meta(path),null,true))
|
||||||
|
throw new Error('Database reflection failed: '+path);
|
||||||
|
}
|
||||||
|
await waitFor('Reflected batch',()=>liveBatch(originalRoot));
|
||||||
|
const expectedPaths=new Set([outsidePath,...notes.map(note=>originalRoot+'/'+note.relativePath)]);
|
||||||
|
const unexpected=app.vault.getFiles().map(file=>file.path).filter(path=>!expectedPaths.has(path));
|
||||||
|
if(unexpected.length) throw new Error('Unexpected reflected files: '+unexpected.join(', '));
|
||||||
const originalIds=await Promise.all(notes.map(async note=>(await meta(originalRoot+'/'+note.relativePath))._id));
|
const originalIds=await Promise.all(notes.map(async note=>(await meta(originalRoot+'/'+note.relativePath))._id));
|
||||||
|
|
||||||
// Rename the parent once: Obsidian must emit every descendant event.
|
// Rename the parent once: Obsidian must emit every descendant event.
|
||||||
@@ -144,7 +163,40 @@ async function main(): Promise<void> {
|
|||||||
if(app.vault.getAbstractFileByPath(renamedRoot)) throw new Error('Deleted folder remains');
|
if(app.vault.getAbstractFileByPath(renamedRoot)) throw new Error('Deleted folder remains');
|
||||||
await app.vault.modify(app.vault.getAbstractFileByPath(outsidePath),'Outside note updated');
|
await app.vault.modify(app.vault.getAbstractFileByPath(outsidePath),'Outside note updated');
|
||||||
await waitFor('Outside update',()=>liveErrors(outsidePath,'Outside note updated'));
|
await waitFor('Outside update',()=>liveErrors(outsidePath,'Outside note updated'));
|
||||||
return JSON.stringify({descendants:notes.length,renamed:renamed.size,deleted:deleted.size});
|
|
||||||
|
// A received database entry must not create a different Vault file when Obsidian rejects its name.
|
||||||
|
const filesBefore=new Set(app.vault.getFiles().map(file=>file.path));
|
||||||
|
const incomingBody='Received colon note\\n';
|
||||||
|
const incomingData=new Blob([incomingBody],{type:'text/plain'});
|
||||||
|
const incomingId=await core.services.path.path2id(missingColonPath);
|
||||||
|
const incomingTime=Date.now();
|
||||||
|
const saved=await core.localDatabase.putDBEntry({
|
||||||
|
_id:incomingId,path:missingColonPath,data:incomingData,
|
||||||
|
ctime:incomingTime,mtime:incomingTime,size:incomingData.size,
|
||||||
|
children:[],datatype:'plain',type:'plain',eden:{},
|
||||||
|
});
|
||||||
|
if(!saved?.ok) throw new Error('Could not seed received Metadata: '+missingColonPath);
|
||||||
|
const incomingMeta=await meta(missingColonPath);
|
||||||
|
if(!incomingMeta || incomingMeta._id!==incomingId || incomingMeta.path!==missingColonPath)
|
||||||
|
throw new Error('Received Metadata has the wrong path: '+missingColonPath);
|
||||||
|
const incomingEntry=await core.localDatabase.getDBEntry(missingColonPath,{rev:incomingMeta._rev},false,true,true);
|
||||||
|
if(!incomingEntry || getContent(incomingEntry)!==incomingBody)
|
||||||
|
throw new Error('Received content could not be read: '+missingColonPath);
|
||||||
|
let creationFailure='';
|
||||||
|
try{
|
||||||
|
const reflected=await core.serviceModules.fileHandler.dbToStorage(incomingMeta,null,true);
|
||||||
|
if(reflected) throw new Error('Obsidian unexpectedly created: '+missingColonPath);
|
||||||
|
}catch(error){
|
||||||
|
creationFailure=String(error);
|
||||||
|
if(!creationFailure.includes('File name cannot contain')) throw error;
|
||||||
|
}
|
||||||
|
if(!creationFailure) throw new Error('Missing name rejection: '+missingColonPath);
|
||||||
|
const filesAfter=app.vault.getFiles().map(file=>file.path);
|
||||||
|
const newFiles=filesAfter.filter(path=>!filesBefore.has(path));
|
||||||
|
if(newFiles.length) throw new Error('Received note was written under another name: '+newFiles.join(', '));
|
||||||
|
if((await meta(missingColonPath))?.path!==missingColonPath)
|
||||||
|
throw new Error('Received Metadata changed after rejection: '+missingColonPath);
|
||||||
|
return JSON.stringify({descendants:notes.length,renamed:renamed.size,deleted:deleted.size,missingRejected:true});
|
||||||
}finally{
|
}finally{
|
||||||
for(const ref of refs) app.vault.offref(ref);
|
for(const ref of refs) app.vault.offref(ref);
|
||||||
}
|
}
|
||||||
@@ -153,7 +205,8 @@ async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
`Folder batch: ${result.descendants} descendants persisted, renamed, and deleted; ` +
|
`Folder batch: ${result.descendants} descendants persisted, renamed, and deleted; ` +
|
||||||
`${result.renamed} rename and ${result.deleted} delete events observed; outside note remained writable.`
|
`${result.renamed} rename and ${result.deleted} delete events observed; outside note remained writable; ` +
|
||||||
|
`missing colon note rejected without an alternate file: ${result.missingRejected}.`
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (session) await session.app.stop();
|
if (session) await session.app.stop();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../r
|
|||||||
import { createTemporaryVault } from "../runner/vault.ts";
|
import { createTemporaryVault } from "../runner/vault.ts";
|
||||||
|
|
||||||
const paths = ["stale-known.md", "stale-unknown.md"];
|
const paths = ["stale-known.md", "stale-unknown.md"];
|
||||||
|
const recoveredPath = "stale-recovered.md";
|
||||||
const oldContent = "# Note\nKeep\n\nTail\n\nFooter\n";
|
const oldContent = "# Note\nKeep\n\nTail\n\nFooter\n";
|
||||||
const newContent = oldContent.replace(
|
const newContent = oldContent.replace(
|
||||||
"Footer\n",
|
"Footer\n",
|
||||||
@@ -26,7 +27,7 @@ async function readState(cliBinary: string, env: NodeJS.ProcessEnv): Promise<Fil
|
|||||||
const core=app.plugins.plugins['obsidian-livesync'].core;
|
const core=app.plugins.plugins['obsidian-livesync'].core;
|
||||||
const store=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');
|
const store=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');
|
||||||
const states=[];
|
const states=[];
|
||||||
for(const path of ${JSON.stringify(paths)}){
|
for(const path of ${JSON.stringify([...paths, recoveredPath])}){
|
||||||
const meta=await core.localDatabase.getDBEntryMeta(path,{conflicts:true},true);
|
const meta=await core.localDatabase.getDBEntryMeta(path,{conflicts:true},true);
|
||||||
const branches=[];
|
const branches=[];
|
||||||
for(const rev of [meta._rev,...(meta._conflicts??[])]){
|
for(const rev of [meta._rev,...(meta._conflicts??[])]){
|
||||||
@@ -82,16 +83,16 @@ async function main(): Promise<void> {
|
|||||||
await evalObsidianJson(
|
await evalObsidianJson(
|
||||||
cliBinary,
|
cliBinary,
|
||||||
`(async()=>{
|
`(async()=>{
|
||||||
for(const path of ${JSON.stringify(paths)}) await app.vault.create(path,${JSON.stringify(oldContent)});
|
for(const path of ${JSON.stringify([...paths, recoveredPath])}) await app.vault.create(path,${JSON.stringify(oldContent)});
|
||||||
return JSON.stringify(true);
|
return JSON.stringify(true);
|
||||||
})()`,
|
})()`,
|
||||||
session.cliEnv
|
session.cliEnv
|
||||||
);
|
);
|
||||||
for (const path of paths) await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path);
|
for (const path of [...paths, recoveredPath]) await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path);
|
||||||
|
|
||||||
// Drain real Vault events before creating a persisted pending-event fixture.
|
// Drain real Vault events before creating a persisted pending-event fixture.
|
||||||
// The DB advances without reflecting it in the Vault, as on an offline device.
|
// The DB advances without reflecting it in the Vault, as on an offline device.
|
||||||
const fixture = await evalObsidianJson<{ current: string[]; original: string[] }>(
|
const fixture = await evalObsidianJson<{ current: string[]; original: string[]; recovered: string }>(
|
||||||
cliBinary,
|
cliBinary,
|
||||||
`(async()=>{
|
`(async()=>{
|
||||||
const core=app.plugins.plugins['obsidian-livesync'].core;
|
const core=app.plugins.plugins['obsidian-livesync'].core;
|
||||||
@@ -110,8 +111,11 @@ async function main(): Promise<void> {
|
|||||||
else await store.delete(path);
|
else await store.delete(path);
|
||||||
snapshot.push({type:'CHANGED',key:'CHANGED-'+path,args:{file}});
|
snapshot.push({type:'CHANGED',key:'CHANGED-'+path,args:{file}});
|
||||||
}
|
}
|
||||||
|
const recovered=${JSON.stringify(recoveredPath)};
|
||||||
|
const recoveredMeta=await core.localDatabase.getDBEntryMeta(recovered,{},true);
|
||||||
|
await store.delete(recovered);
|
||||||
await core.kvDB.set('storage-event-manager-snapshot',snapshot);
|
await core.kvDB.set('storage-event-manager-snapshot',snapshot);
|
||||||
return JSON.stringify({current,original});
|
return JSON.stringify({current,original,recovered:recoveredMeta._rev});
|
||||||
})()`,
|
})()`,
|
||||||
session.cliEnv
|
session.cliEnv
|
||||||
);
|
);
|
||||||
@@ -120,7 +124,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
session = await startObsidianLiveSyncSession({ binary, cliBinary, vault });
|
session = await startObsidianLiveSyncSession({ binary, cliBinary, vault });
|
||||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||||
const [known, unknown] = await readState(cliBinary, session.cliEnv);
|
const [known, unknown, recovered] = await readState(cliBinary, session.cliEnv);
|
||||||
assertEqual(known.rev, fixture.current[0], "An unchanged stale file created a revision during restart.");
|
assertEqual(known.rev, fixture.current[0], "An unchanged stale file created a revision during restart.");
|
||||||
assertEqual(known.branches.length, 1, "An unchanged stale file created a conflict.");
|
assertEqual(known.branches.length, 1, "An unchanged stale file created a conflict.");
|
||||||
assertEqual(known.content, newContent, "The newer DB content was not reflected after suppressing the save.");
|
assertEqual(known.content, newContent, "The newer DB content was not reflected after suppressing the save.");
|
||||||
@@ -134,6 +138,32 @@ async function main(): Promise<void> {
|
|||||||
if (!unknown.branches.some((branch) => branch.content === newContent)) {
|
if (!unknown.branches.some((branch) => branch.content === newContent)) {
|
||||||
throw new Error("The remote additions were lost.");
|
throw new Error("The remote additions were lost.");
|
||||||
}
|
}
|
||||||
|
assertEqual(recovered.rev, fixture.recovered, "Unchanged recovery added a database revision.");
|
||||||
|
assertEqual(recovered.branches.length, 1, "Unchanged recovery created a conflict.");
|
||||||
|
assertEqual(recovered.content, oldContent, "Unchanged recovery modified the Vault file.");
|
||||||
|
assertEqual(recovered.provenance, fixture.recovered, "Start-up did not recover missing file provenance.");
|
||||||
|
|
||||||
|
const receivedRevision = await evalObsidianJson<string>(
|
||||||
|
cliBinary,
|
||||||
|
`(async()=>{
|
||||||
|
const core=app.plugins.plugins['obsidian-livesync'].core;
|
||||||
|
const path=${JSON.stringify(recoveredPath)};
|
||||||
|
const meta=await core.localDatabase.getDBEntryMeta(path,{},true);
|
||||||
|
const file=await core.storageAccess.getFileStub(path);
|
||||||
|
const data=new Blob([${JSON.stringify(newContent)}],{type:'text/plain'});
|
||||||
|
const result=await core.localDatabase.putDBEntry({...meta,data,mtime:file.stat.mtime+60000,
|
||||||
|
size:data.size,children:[]},false,meta._rev);
|
||||||
|
if(!result?.ok) throw new Error('Could not advance '+path);
|
||||||
|
if(!await core.fileHandler.dbToStorage(path,path,false)) throw new Error('Could not reflect '+path);
|
||||||
|
return JSON.stringify(result.rev);
|
||||||
|
})()`,
|
||||||
|
session.cliEnv
|
||||||
|
);
|
||||||
|
const [, , received] = await readState(cliBinary, session.cliEnv);
|
||||||
|
assertEqual(received.rev, receivedRevision, "Incoming revision was not retained.");
|
||||||
|
assertEqual(received.branches.length, 1, "Incoming revision created a conflict after recovery.");
|
||||||
|
assertEqual(received.content, newContent, "Incoming revision was not reflected after recovery.");
|
||||||
|
assertEqual(received.provenance, receivedRevision, "Incoming reflection did not record its revision.");
|
||||||
|
|
||||||
await evalObsidianJson(
|
await evalObsidianJson(
|
||||||
cliBinary,
|
cliBinary,
|
||||||
@@ -174,7 +204,7 @@ async function main(): Promise<void> {
|
|||||||
assertEqual(resolved.branches.length, 1, "The explicit newer-file option did not resolve the conflict.");
|
assertEqual(resolved.branches.length, 1, "The explicit newer-file option did not resolve the conflict.");
|
||||||
assertEqual(resolved.content, newContent, "The newer-file option did not reflect the newer DB version.");
|
assertEqual(resolved.content, newContent, "The newer-file option did not reflect the newer DB version.");
|
||||||
console.log(
|
console.log(
|
||||||
"Stale-file restart: known content reflected; unknown content preserved without duplicate branches; explicit newer-file resolution retained."
|
"Stale-file restart: missing unchanged provenance recovered; later update reflected; unknown content preserved; explicit newer-file resolution retained."
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (session) await session.app.stop();
|
if (session) await session.app.stop();
|
||||||
|
|||||||
+31
-58
@@ -12,6 +12,37 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 1.0.32
|
||||||
|
|
||||||
|
27th September, 2026
|
||||||
|
|
||||||
|
The 1.0.31 pre-release was not promoted after validation found that a receiving device could reject encrypted CouchDB changes when Path Obfuscation was enabled. This release includes its changes and corrects that issue.
|
||||||
|
|
||||||
|
### Synchronisation and storage
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- The receiving device now accepts encrypted file information when both end-to-end encryption and Path Obfuscation are enabled. The 1.0.31 pre-release could reject this information, leaving files from another device absent from the Vault.
|
||||||
|
- Files with colons in their names now retain their full paths in synchronisation data instead of appearing as incorrectly named copies at the Vault root. (#1206)
|
||||||
|
- Obsidian may refuse to create a missing file with such a name. LiveSync also treats these names as invalid on Windows and Android, so the file may not appear in those devices' Vaults. Existing misplaced copies are left for you to review; this change does not remove them automatically.
|
||||||
|
- Received changes within the configured modification-time limit are applied to the Vault again while remediation mode is active. Changes newer than the limit remain blocked; changes arriving while a fetch makes the local database unavailable are kept for a later attempt.
|
||||||
|
- A scheduled fetch no longer offers Simple Fetch while remediation mode is active. This prevents the fetch from bypassing the modification-time limit; the detailed flow explains the restriction and offers to clear it first (#1202). Thank you to @kimjansheden for both fixes and the regression tests in PR #1208!
|
||||||
|
- On start-up, an unchanged file with a missing local revision record can be recognised before newer content arrives, avoiding an unnecessary conflict. Files with actual local edits still require conflict review. (#1207)
|
||||||
|
|
||||||
|
## 1.0.31
|
||||||
|
|
||||||
|
26th September, 2026
|
||||||
|
|
||||||
|
### Synchronisation and storage
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- Files with colons in their names now retain their full paths in synchronisation data instead of appearing as incorrectly named copies at the Vault root. (#1206)
|
||||||
|
- Obsidian may refuse to create a missing file with such a name. LiveSync also treats these names as invalid on Windows and Android, so the file may not appear in those devices' Vaults. Existing misplaced copies are left for you to review; this change does not remove them automatically.
|
||||||
|
- Received changes within the configured modification-time limit are applied to the Vault again while remediation mode is active. Changes newer than the limit remain blocked; changes arriving while a fetch makes the local database unavailable are kept for a later attempt.
|
||||||
|
- A scheduled fetch no longer offers Simple Fetch while remediation mode is active. This prevents the fetch from bypassing the modification-time limit; the detailed flow explains the restriction and offers to clear it first (#1202). Thank you to @kimjansheden for both fixes and the regression tests in PR #1208!
|
||||||
|
- On start-up, an unchanged file with a missing local revision record can be recognised before newer content arrives, avoiding an unnecessary conflict. Files with actual local edits still require conflict review. (#1207)
|
||||||
|
|
||||||
## 1.0.30
|
## 1.0.30
|
||||||
|
|
||||||
18th September, 2026
|
18th September, 2026
|
||||||
@@ -77,61 +108,3 @@ For now, I am addressing the issues I can resolve first. I hope this helps.
|
|||||||
#### Fixed
|
#### Fixed
|
||||||
|
|
||||||
- First-time Object Storage setup now completes when **Use Custom HTTP Handler** is enabled for an empty remote, including a new Cloudflare R2 bucket. LiveSync can now create the remote state required to begin synchronisation. (#1166)
|
- First-time Object Storage setup now completes when **Use Custom HTTP Handler** is enabled for an empty remote, including a new Cloudflare R2 bucket. LiveSync can now create the remote state required to begin synchronisation. (#1166)
|
||||||
|
|
||||||
## 1.0.26
|
|
||||||
|
|
||||||
~~1.0.25~~ was cancelled because pre-release validation found that LiveSync could appear to finish synchronising even though Android had not written a received file to the Vault; the warning appeared only after restart.
|
|
||||||
|
|
||||||
6th September, 2026
|
|
||||||
|
|
||||||
### Synchronisation and storage
|
|
||||||
|
|
||||||
#### Fixed
|
|
||||||
|
|
||||||
- Files inside a folder are no longer silently removed from synchronisation when an external tool changes only the letter case of that folder while Obsidian is running. This prevents the stale deletion from reaching other devices or later removing the local file. Moving files into ignored or otherwise excluded locations retains the existing behaviour, and the folder-name case itself may still differ between devices. (#1168)
|
|
||||||
- A problem processing one file during ordinary start-up no longer prevents every other file from synchronising. LiveSync warns about the affected files and can retry them later; Fetch and Rebuild still stop if they cannot finish safely. (#1164)
|
|
||||||
- When LiveSync cannot finish preparing this device for synchronisation, it now says that synchronisation is unavailable and directs you to generate a report, instead of remaining at 'Not ready'. (#1164)
|
|
||||||
|
|
||||||
#### Improved
|
|
||||||
|
|
||||||
- When LiveSync cannot write a received file to the Vault, it now warns immediately instead of appearing to have synchronised it successfully. The generated report identifies the affected path, and a later scan can try it again.
|
|
||||||
|
|
||||||
### Conflict handling and recovery
|
|
||||||
|
|
||||||
#### Improved
|
|
||||||
|
|
||||||
- Conflict resolution dialogues now close when the same file is resolved elsewhere or when the plug-in unloads. Requests for different files are shown one at a time, while a newer request for the same file replaces the older one.
|
|
||||||
|
|
||||||
### Setup and compatibility
|
|
||||||
|
|
||||||
#### Improved
|
|
||||||
|
|
||||||
- Unconfigured Vaults now stay focused on setup instead of running Config Doctor or incomplete-document checks before they can be used. Returning a configured Vault to an unconfigured state also stops those checks until the requested restart. (#1161)
|
|
||||||
- When the active file contains a file or folder name longer than 255 UTF-8 bytes, LiveSync now explains that the path may not work on some Android and Linux file systems. It does not rename or reject the file. (#1164)
|
|
||||||
|
|
||||||
## 1.0.24
|
|
||||||
|
|
||||||
3rd September, 2026
|
|
||||||
|
|
||||||
### Interface and translation
|
|
||||||
|
|
||||||
#### Fixed
|
|
||||||
|
|
||||||
- The Setup Wizard now correctly explains that the existing-device path adds this device to an existing synchronisation (PR #1118). Thank you to @nikhilmaddirala for the contribution!
|
|
||||||
- Spanish translations now resolve the **Display language** placeholder, cover previously untranslated Setup Wizard and CouchDB text, translate user-facing Config Doctor values and confirmation controls, and use Spanish sentence case (PR #1129). Thank you to @zeedif for the contribution!
|
|
||||||
|
|
||||||
#### Improved
|
|
||||||
|
|
||||||
- The Setup Wizard now shows the passphrase and **Obfuscate Properties** controls only after E2EE is enabled, provides a password-visibility button, allows longer translated labels to wrap, and keeps the invitation link compact on desktop while preserving its mobile touch target (PR #1130). Thank you to @zeedif for the contribution!
|
|
||||||
|
|
||||||
### Synchronisation and storage
|
|
||||||
|
|
||||||
#### Fixed
|
|
||||||
|
|
||||||
- **Overwrite Server Data with This Device's Files** now keeps this device's synchronisation settings instead of reapplying settings from the remote database which is about to be replaced. Enabling E2EE before a rebuild therefore remains enabled and uploads encrypted data. (#1146)
|
|
||||||
|
|
||||||
### Command-line tool
|
|
||||||
|
|
||||||
#### Fixed
|
|
||||||
|
|
||||||
- The systemd installer now finds the repository root correctly, installs every generated bundle chunk and required production dependency, checks the installed command before activation, and reports success only when the service remains active.
|
|
||||||
|
|||||||
+3
-1
@@ -41,5 +41,7 @@
|
|||||||
"1.0.27": "1.7.2",
|
"1.0.27": "1.7.2",
|
||||||
"1.0.28": "1.7.2",
|
"1.0.28": "1.7.2",
|
||||||
"1.0.29": "1.7.2",
|
"1.0.29": "1.7.2",
|
||||||
"1.0.30": "1.7.2"
|
"1.0.30": "1.7.2",
|
||||||
|
"1.0.31": "1.7.2",
|
||||||
|
"1.0.32": "1.7.2"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user