feat(plugin): per-client path mapping (PathMapper)
Vault-relative paths matching `.obsidian/workspace.json`, `.obsidian/
cache`, `.obsidian/types.json`, etc. are redirected into a
per-client subtree on the remote (`.obsidian/user/<client-id>/...`)
so two machines on the same vault don't trample each other's UI
state. The client id defaults to a sanitized OS hostname; multi-
instance setups can override it later when a settings field lands.
src/path/PathMapper.ts
- DEFAULT_PRIVATE_PATTERNS: workspace.json, workspace-mobile.json,
cache, cache.zlib, types.json, file-recovery.json, graph.json,
canvas.json. Errors on the side of "private" — the cost of an
unnecessary per-machine copy is low; the cost of a shared
workspace.json being clobbered is loud.
- isPrivate(path): exact match or directory-prefix match against
the patterns. Tolerates a leading slash on the input.
- isCrossingPoint(path): true when `path` is the parent of one or
more private patterns but not itself private. Today this matches
exactly `.obsidian` — listing it requires merging the shared
remote `.obsidian/` with the per-client subtree.
- toRemote / toVault: round-trip path translation. Foreign clients'
subtrees are preserved unchanged on the way back so the caller
can decide whether to filter them out.
- resolveListing(path): planning helper for `list()` — returns
{ primary, mergeFromUser, userSubtree?, hideUserDirName? }.
- defaultClientId() / sanitizeClientId(): hostname-based id with
unsafe characters replaced by hyphens; falls back to "unknown"
when sanitization yields an empty string.
src/adapter/SftpDataAdapter.ts
- Optional 6th constructor param `pathMapper`. When supplied,
`toRemote()` first runs the vault path through the mapper, then
joins with `remoteBasePath` as before. A new private `joinRemote`
exposes the join-only step for the listing planner.
- `list()` consults the mapper's `resolveListing(...)` plan: it
always lists the primary path (and prunes the `user/` directory
on a crossing-point listing), then optionally lists the user
subtree and merges the entries. User-subtree entries take
precedence on name conflicts so private files always show up
under their nominal `.obsidian/...` name.
- A small `planList(path)` shim exists so adapter tests can see
what the mapper decided without going through a real
RemoteFsClient.
src/main.ts
- `debugPatchAdapter` constructs a PathMapper with `defaultClientId`
and passes it to the adapter on every patch. The clientId is
logged so diagnostics in console.log show which subtree the
current session writes into.
Tests
- tests/PathMapper.test.ts (22 cases): isPrivate matching including
prefix/sibling distinction, isCrossingPoint, toRemote/toVault
round-trip, foreign-client subtree pass-through, resolveListing
for `.obsidian` (merge with hidden user/), private-dir
redirection, ordinary path pass-through, custom-pattern
override, and the sanitizeClientId helper for the canonical
cases (alphanumerics + dot/hyphen/underscore preserved, unsafe
characters folded, empty-after-sanitization → "unknown").
- tests/SftpDataAdapter.test.ts grew a "with PathMapper" describe
block (5 cases): writes to private paths land in the per-client
subtree, reads come back from there, non-private vault content is
not redirected, `.obsidian` listing merges shared + per-client
while hiding the user/ dir, and a private-directory listing
walks the per-client subtree.
Verification
- cd plugin && npx tsc --noEmit clean
- cd plugin && npm test 151 / 151 pass (28 new)
- cd plugin && npm run build:full all green; bundle 386 KB
(+1 KB over Phase 5-D.5).
Follow-ups
- Settings field for explicit clientId overrides (multi-instance
setups, anonymisation).
- Bootstrap: copy local `.obsidian/` skeleton into the per-client
subtree on first connect so Obsidian's first read of
workspace.json doesn't fail.
- fs.watch (Phase 5-E) and getResourcePath (Phase 5-F) are still
the next big features.
2026-04-25 09:07:13 +00:00
|
|
|
import { describe, it, expect } from 'vitest';
|
2026-04-26 01:46:53 +00:00
|
|
|
import {
|
|
|
|
|
PathMapper,
|
|
|
|
|
sanitizeClientId,
|
|
|
|
|
defaultClientId,
|
|
|
|
|
defaultUserName,
|
|
|
|
|
DEFAULT_PRIVATE_PATTERNS,
|
|
|
|
|
} from '../src/path/PathMapper';
|
feat(plugin): per-client path mapping (PathMapper)
Vault-relative paths matching `.obsidian/workspace.json`, `.obsidian/
cache`, `.obsidian/types.json`, etc. are redirected into a
per-client subtree on the remote (`.obsidian/user/<client-id>/...`)
so two machines on the same vault don't trample each other's UI
state. The client id defaults to a sanitized OS hostname; multi-
instance setups can override it later when a settings field lands.
src/path/PathMapper.ts
- DEFAULT_PRIVATE_PATTERNS: workspace.json, workspace-mobile.json,
cache, cache.zlib, types.json, file-recovery.json, graph.json,
canvas.json. Errors on the side of "private" — the cost of an
unnecessary per-machine copy is low; the cost of a shared
workspace.json being clobbered is loud.
- isPrivate(path): exact match or directory-prefix match against
the patterns. Tolerates a leading slash on the input.
- isCrossingPoint(path): true when `path` is the parent of one or
more private patterns but not itself private. Today this matches
exactly `.obsidian` — listing it requires merging the shared
remote `.obsidian/` with the per-client subtree.
- toRemote / toVault: round-trip path translation. Foreign clients'
subtrees are preserved unchanged on the way back so the caller
can decide whether to filter them out.
- resolveListing(path): planning helper for `list()` — returns
{ primary, mergeFromUser, userSubtree?, hideUserDirName? }.
- defaultClientId() / sanitizeClientId(): hostname-based id with
unsafe characters replaced by hyphens; falls back to "unknown"
when sanitization yields an empty string.
src/adapter/SftpDataAdapter.ts
- Optional 6th constructor param `pathMapper`. When supplied,
`toRemote()` first runs the vault path through the mapper, then
joins with `remoteBasePath` as before. A new private `joinRemote`
exposes the join-only step for the listing planner.
- `list()` consults the mapper's `resolveListing(...)` plan: it
always lists the primary path (and prunes the `user/` directory
on a crossing-point listing), then optionally lists the user
subtree and merges the entries. User-subtree entries take
precedence on name conflicts so private files always show up
under their nominal `.obsidian/...` name.
- A small `planList(path)` shim exists so adapter tests can see
what the mapper decided without going through a real
RemoteFsClient.
src/main.ts
- `debugPatchAdapter` constructs a PathMapper with `defaultClientId`
and passes it to the adapter on every patch. The clientId is
logged so diagnostics in console.log show which subtree the
current session writes into.
Tests
- tests/PathMapper.test.ts (22 cases): isPrivate matching including
prefix/sibling distinction, isCrossingPoint, toRemote/toVault
round-trip, foreign-client subtree pass-through, resolveListing
for `.obsidian` (merge with hidden user/), private-dir
redirection, ordinary path pass-through, custom-pattern
override, and the sanitizeClientId helper for the canonical
cases (alphanumerics + dot/hyphen/underscore preserved, unsafe
characters folded, empty-after-sanitization → "unknown").
- tests/SftpDataAdapter.test.ts grew a "with PathMapper" describe
block (5 cases): writes to private paths land in the per-client
subtree, reads come back from there, non-private vault content is
not redirected, `.obsidian` listing merges shared + per-client
while hiding the user/ dir, and a private-directory listing
walks the per-client subtree.
Verification
- cd plugin && npx tsc --noEmit clean
- cd plugin && npm test 151 / 151 pass (28 new)
- cd plugin && npm run build:full all green; bundle 386 KB
(+1 KB over Phase 5-D.5).
Follow-ups
- Settings field for explicit clientId overrides (multi-instance
setups, anonymisation).
- Bootstrap: copy local `.obsidian/` skeleton into the per-client
subtree on first connect so Obsidian's first read of
workspace.json doesn't fail.
- fs.watch (Phase 5-E) and getResourcePath (Phase 5-F) are still
the next big features.
2026-04-25 09:07:13 +00:00
|
|
|
|
|
|
|
|
const ID = 'host-a';
|
|
|
|
|
|
|
|
|
|
describe('sanitizeClientId', () => {
|
|
|
|
|
it('passes through ASCII alphanumerics + dot/hyphen/underscore', () => {
|
|
|
|
|
expect(sanitizeClientId('GERMI')).toBe('GERMI');
|
|
|
|
|
expect(sanitizeClientId('node-1.local_2')).toBe('node-1.local_2');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('replaces unsafe characters with hyphen and trims them at the edges', () => {
|
|
|
|
|
expect(sanitizeClientId('host with spaces')).toBe('host-with-spaces');
|
|
|
|
|
expect(sanitizeClientId('!!evil!!')).toBe('evil');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('falls back to "unknown" when the input is empty after sanitisation', () => {
|
|
|
|
|
expect(sanitizeClientId('')).toBe('unknown');
|
|
|
|
|
expect(sanitizeClientId('!!!')).toBe('unknown');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-26 01:46:53 +00:00
|
|
|
describe('defaultClientId / defaultUserName', () => {
|
|
|
|
|
it('defaultClientId returns a non-empty sanitized string', () => {
|
|
|
|
|
const id = defaultClientId();
|
|
|
|
|
expect(id).not.toBe('');
|
|
|
|
|
// The same string should pass through sanitize unchanged.
|
|
|
|
|
expect(sanitizeClientId(id)).toBe(id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('defaultUserName returns a non-empty string', () => {
|
|
|
|
|
const u = defaultUserName();
|
|
|
|
|
expect(u).not.toBe('');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat(plugin): per-client path mapping (PathMapper)
Vault-relative paths matching `.obsidian/workspace.json`, `.obsidian/
cache`, `.obsidian/types.json`, etc. are redirected into a
per-client subtree on the remote (`.obsidian/user/<client-id>/...`)
so two machines on the same vault don't trample each other's UI
state. The client id defaults to a sanitized OS hostname; multi-
instance setups can override it later when a settings field lands.
src/path/PathMapper.ts
- DEFAULT_PRIVATE_PATTERNS: workspace.json, workspace-mobile.json,
cache, cache.zlib, types.json, file-recovery.json, graph.json,
canvas.json. Errors on the side of "private" — the cost of an
unnecessary per-machine copy is low; the cost of a shared
workspace.json being clobbered is loud.
- isPrivate(path): exact match or directory-prefix match against
the patterns. Tolerates a leading slash on the input.
- isCrossingPoint(path): true when `path` is the parent of one or
more private patterns but not itself private. Today this matches
exactly `.obsidian` — listing it requires merging the shared
remote `.obsidian/` with the per-client subtree.
- toRemote / toVault: round-trip path translation. Foreign clients'
subtrees are preserved unchanged on the way back so the caller
can decide whether to filter them out.
- resolveListing(path): planning helper for `list()` — returns
{ primary, mergeFromUser, userSubtree?, hideUserDirName? }.
- defaultClientId() / sanitizeClientId(): hostname-based id with
unsafe characters replaced by hyphens; falls back to "unknown"
when sanitization yields an empty string.
src/adapter/SftpDataAdapter.ts
- Optional 6th constructor param `pathMapper`. When supplied,
`toRemote()` first runs the vault path through the mapper, then
joins with `remoteBasePath` as before. A new private `joinRemote`
exposes the join-only step for the listing planner.
- `list()` consults the mapper's `resolveListing(...)` plan: it
always lists the primary path (and prunes the `user/` directory
on a crossing-point listing), then optionally lists the user
subtree and merges the entries. User-subtree entries take
precedence on name conflicts so private files always show up
under their nominal `.obsidian/...` name.
- A small `planList(path)` shim exists so adapter tests can see
what the mapper decided without going through a real
RemoteFsClient.
src/main.ts
- `debugPatchAdapter` constructs a PathMapper with `defaultClientId`
and passes it to the adapter on every patch. The clientId is
logged so diagnostics in console.log show which subtree the
current session writes into.
Tests
- tests/PathMapper.test.ts (22 cases): isPrivate matching including
prefix/sibling distinction, isCrossingPoint, toRemote/toVault
round-trip, foreign-client subtree pass-through, resolveListing
for `.obsidian` (merge with hidden user/), private-dir
redirection, ordinary path pass-through, custom-pattern
override, and the sanitizeClientId helper for the canonical
cases (alphanumerics + dot/hyphen/underscore preserved, unsafe
characters folded, empty-after-sanitization → "unknown").
- tests/SftpDataAdapter.test.ts grew a "with PathMapper" describe
block (5 cases): writes to private paths land in the per-client
subtree, reads come back from there, non-private vault content is
not redirected, `.obsidian` listing merges shared + per-client
while hiding the user/ dir, and a private-directory listing
walks the per-client subtree.
Verification
- cd plugin && npx tsc --noEmit clean
- cd plugin && npm test 151 / 151 pass (28 new)
- cd plugin && npm run build:full all green; bundle 386 KB
(+1 KB over Phase 5-D.5).
Follow-ups
- Settings field for explicit clientId overrides (multi-instance
setups, anonymisation).
- Bootstrap: copy local `.obsidian/` skeleton into the per-client
subtree on first connect so Obsidian's first read of
workspace.json doesn't fail.
- fs.watch (Phase 5-E) and getResourcePath (Phase 5-F) are still
the next big features.
2026-04-25 09:07:13 +00:00
|
|
|
describe('PathMapper.isPrivate', () => {
|
|
|
|
|
const m = new PathMapper(ID);
|
|
|
|
|
|
|
|
|
|
it('matches the canonical private files', () => {
|
|
|
|
|
expect(m.isPrivate('.obsidian/workspace.json')).toBe(true);
|
|
|
|
|
expect(m.isPrivate('.obsidian/cache.zlib')).toBe(true);
|
|
|
|
|
expect(m.isPrivate('.obsidian/types.json')).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-02 12:41:35 +00:00
|
|
|
it('treats per-device settings (app/appearance/core-plugins/hotkeys) as private', () => {
|
|
|
|
|
// Moved from shared → per-client so two devices (or one device
|
|
|
|
|
// across reconnects) never collide on a single shared copy — the
|
|
|
|
|
// perpetual write-conflict this fixes.
|
|
|
|
|
expect(m.isPrivate('.obsidian/app.json')).toBe(true);
|
|
|
|
|
expect(m.isPrivate('.obsidian/appearance.json')).toBe(true);
|
|
|
|
|
expect(m.isPrivate('.obsidian/core-plugins.json')).toBe(true);
|
|
|
|
|
expect(m.isPrivate('.obsidian/hotkeys.json')).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
feat(plugin): per-client path mapping (PathMapper)
Vault-relative paths matching `.obsidian/workspace.json`, `.obsidian/
cache`, `.obsidian/types.json`, etc. are redirected into a
per-client subtree on the remote (`.obsidian/user/<client-id>/...`)
so two machines on the same vault don't trample each other's UI
state. The client id defaults to a sanitized OS hostname; multi-
instance setups can override it later when a settings field lands.
src/path/PathMapper.ts
- DEFAULT_PRIVATE_PATTERNS: workspace.json, workspace-mobile.json,
cache, cache.zlib, types.json, file-recovery.json, graph.json,
canvas.json. Errors on the side of "private" — the cost of an
unnecessary per-machine copy is low; the cost of a shared
workspace.json being clobbered is loud.
- isPrivate(path): exact match or directory-prefix match against
the patterns. Tolerates a leading slash on the input.
- isCrossingPoint(path): true when `path` is the parent of one or
more private patterns but not itself private. Today this matches
exactly `.obsidian` — listing it requires merging the shared
remote `.obsidian/` with the per-client subtree.
- toRemote / toVault: round-trip path translation. Foreign clients'
subtrees are preserved unchanged on the way back so the caller
can decide whether to filter them out.
- resolveListing(path): planning helper for `list()` — returns
{ primary, mergeFromUser, userSubtree?, hideUserDirName? }.
- defaultClientId() / sanitizeClientId(): hostname-based id with
unsafe characters replaced by hyphens; falls back to "unknown"
when sanitization yields an empty string.
src/adapter/SftpDataAdapter.ts
- Optional 6th constructor param `pathMapper`. When supplied,
`toRemote()` first runs the vault path through the mapper, then
joins with `remoteBasePath` as before. A new private `joinRemote`
exposes the join-only step for the listing planner.
- `list()` consults the mapper's `resolveListing(...)` plan: it
always lists the primary path (and prunes the `user/` directory
on a crossing-point listing), then optionally lists the user
subtree and merges the entries. User-subtree entries take
precedence on name conflicts so private files always show up
under their nominal `.obsidian/...` name.
- A small `planList(path)` shim exists so adapter tests can see
what the mapper decided without going through a real
RemoteFsClient.
src/main.ts
- `debugPatchAdapter` constructs a PathMapper with `defaultClientId`
and passes it to the adapter on every patch. The clientId is
logged so diagnostics in console.log show which subtree the
current session writes into.
Tests
- tests/PathMapper.test.ts (22 cases): isPrivate matching including
prefix/sibling distinction, isCrossingPoint, toRemote/toVault
round-trip, foreign-client subtree pass-through, resolveListing
for `.obsidian` (merge with hidden user/), private-dir
redirection, ordinary path pass-through, custom-pattern
override, and the sanitizeClientId helper for the canonical
cases (alphanumerics + dot/hyphen/underscore preserved, unsafe
characters folded, empty-after-sanitization → "unknown").
- tests/SftpDataAdapter.test.ts grew a "with PathMapper" describe
block (5 cases): writes to private paths land in the per-client
subtree, reads come back from there, non-private vault content is
not redirected, `.obsidian` listing merges shared + per-client
while hiding the user/ dir, and a private-directory listing
walks the per-client subtree.
Verification
- cd plugin && npx tsc --noEmit clean
- cd plugin && npm test 151 / 151 pass (28 new)
- cd plugin && npm run build:full all green; bundle 386 KB
(+1 KB over Phase 5-D.5).
Follow-ups
- Settings field for explicit clientId overrides (multi-instance
setups, anonymisation).
- Bootstrap: copy local `.obsidian/` skeleton into the per-client
subtree on first connect so Obsidian's first read of
workspace.json doesn't fail.
- fs.watch (Phase 5-E) and getResourcePath (Phase 5-F) are still
the next big features.
2026-04-25 09:07:13 +00:00
|
|
|
it('matches inside a private directory pattern', () => {
|
|
|
|
|
expect(m.isPrivate('.obsidian/cache/index')).toBe(true);
|
|
|
|
|
expect(m.isPrivate('.obsidian/cache/sub/x.bin')).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects sibling paths that share a prefix', () => {
|
|
|
|
|
expect(m.isPrivate('.obsidian/cache.zlib2')).toBe(false);
|
|
|
|
|
expect(m.isPrivate('.obsidian/workspace.json.bak')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects regular vault content', () => {
|
|
|
|
|
expect(m.isPrivate('Notes/foo.md')).toBe(false);
|
2026-07-02 12:41:35 +00:00
|
|
|
// community-plugins.json stays shared (round-tripped with a forced
|
|
|
|
|
// remote-ssh union), so it is NOT redirected per-client.
|
|
|
|
|
expect(m.isPrivate('.obsidian/community-plugins.json')).toBe(false);
|
feat(plugin): per-client path mapping (PathMapper)
Vault-relative paths matching `.obsidian/workspace.json`, `.obsidian/
cache`, `.obsidian/types.json`, etc. are redirected into a
per-client subtree on the remote (`.obsidian/user/<client-id>/...`)
so two machines on the same vault don't trample each other's UI
state. The client id defaults to a sanitized OS hostname; multi-
instance setups can override it later when a settings field lands.
src/path/PathMapper.ts
- DEFAULT_PRIVATE_PATTERNS: workspace.json, workspace-mobile.json,
cache, cache.zlib, types.json, file-recovery.json, graph.json,
canvas.json. Errors on the side of "private" — the cost of an
unnecessary per-machine copy is low; the cost of a shared
workspace.json being clobbered is loud.
- isPrivate(path): exact match or directory-prefix match against
the patterns. Tolerates a leading slash on the input.
- isCrossingPoint(path): true when `path` is the parent of one or
more private patterns but not itself private. Today this matches
exactly `.obsidian` — listing it requires merging the shared
remote `.obsidian/` with the per-client subtree.
- toRemote / toVault: round-trip path translation. Foreign clients'
subtrees are preserved unchanged on the way back so the caller
can decide whether to filter them out.
- resolveListing(path): planning helper for `list()` — returns
{ primary, mergeFromUser, userSubtree?, hideUserDirName? }.
- defaultClientId() / sanitizeClientId(): hostname-based id with
unsafe characters replaced by hyphens; falls back to "unknown"
when sanitization yields an empty string.
src/adapter/SftpDataAdapter.ts
- Optional 6th constructor param `pathMapper`. When supplied,
`toRemote()` first runs the vault path through the mapper, then
joins with `remoteBasePath` as before. A new private `joinRemote`
exposes the join-only step for the listing planner.
- `list()` consults the mapper's `resolveListing(...)` plan: it
always lists the primary path (and prunes the `user/` directory
on a crossing-point listing), then optionally lists the user
subtree and merges the entries. User-subtree entries take
precedence on name conflicts so private files always show up
under their nominal `.obsidian/...` name.
- A small `planList(path)` shim exists so adapter tests can see
what the mapper decided without going through a real
RemoteFsClient.
src/main.ts
- `debugPatchAdapter` constructs a PathMapper with `defaultClientId`
and passes it to the adapter on every patch. The clientId is
logged so diagnostics in console.log show which subtree the
current session writes into.
Tests
- tests/PathMapper.test.ts (22 cases): isPrivate matching including
prefix/sibling distinction, isCrossingPoint, toRemote/toVault
round-trip, foreign-client subtree pass-through, resolveListing
for `.obsidian` (merge with hidden user/), private-dir
redirection, ordinary path pass-through, custom-pattern
override, and the sanitizeClientId helper for the canonical
cases (alphanumerics + dot/hyphen/underscore preserved, unsafe
characters folded, empty-after-sanitization → "unknown").
- tests/SftpDataAdapter.test.ts grew a "with PathMapper" describe
block (5 cases): writes to private paths land in the per-client
subtree, reads come back from there, non-private vault content is
not redirected, `.obsidian` listing merges shared + per-client
while hiding the user/ dir, and a private-directory listing
walks the per-client subtree.
Verification
- cd plugin && npx tsc --noEmit clean
- cd plugin && npm test 151 / 151 pass (28 new)
- cd plugin && npm run build:full all green; bundle 386 KB
(+1 KB over Phase 5-D.5).
Follow-ups
- Settings field for explicit clientId overrides (multi-instance
setups, anonymisation).
- Bootstrap: copy local `.obsidian/` skeleton into the per-client
subtree on first connect so Obsidian's first read of
workspace.json doesn't fail.
- fs.watch (Phase 5-E) and getResourcePath (Phase 5-F) are still
the next big features.
2026-04-25 09:07:13 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('tolerates a leading slash on the input', () => {
|
|
|
|
|
expect(m.isPrivate('/.obsidian/workspace.json')).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
fix(config): persist third-party plugin settings across restarts (#342, #429)
Plugin settings were lost on every restart of the shadow vault — and the
real settings on the remote were destroyed in the process.
Root cause is an ordering problem the connect-time pull cannot fix.
Obsidian loads community plugins during startup, and each plugin's
`onload()` calls `Plugin.loadData()` -> reads
`<configDir>/plugins/<id>/data.json` — BEFORE remote-ssh has connected
over SSH and patched the adapter. That read therefore always hits the
real FileSystemAdapter, i.e. the LOCAL shadow disk. We cannot get in
front of it: the SSH connect is async and lands at layout-ready.
Nothing ever wrote that local copy. `saveData()` went through the
patched adapter straight to the remote, so the local file stayed empty
and every restart booted the plugin on DEFAULTS — which the plugin then
saved back, overwriting the good settings on the remote too. The plugin
CODE survived (main.js/manifest.json/styles.css are round-tripped by
PLUGIN_BINARY_FILES), which is exactly the reported asymmetry: "the
plugin is still installed, but its settings are gone".
Two changes:
1. SftpDataAdapter: write-through. A successful `<configDir>/**` write is
now mirrored onto the local shadow disk with the same bytes that
landed on the remote. The local disk becomes a warm cache, so the
startup read sees the settings this device last saved no matter how
the shadow window was launched (Connect from the source window, or
reopening the vault directly — the latter never runs preSpawnPull).
Best-effort: a mirror failure never fails the remote write. The NOTE
tree stays virtual — only the configDir is mirrored.
2. PathMapper: `plugins/*/data.json` is now per-device, redirected into
`<configDir>/user/<clientId>/`. `saveData()` fires on every settings
change, so a shared remote path would reintroduce the perpetual
write-conflict that the four core config files were redirected to
escape (b0e2773). Deliberately `plugins/*/data.json` and NOT
`plugins` — the plugin's CODE stays SHARED at the identity path so a
plugin installed on one machine still loads on every other. This
required teaching the pattern matcher a `*` segment (exactly one
path segment, never across `/`), and making crossing-point detection
wildcard-aware so listing a plugin's own dir merges in this client's
private data.json.
Multi-device is robust by construction: two devices can no longer
collide on one remote settings file.
Tests: new PathMapper cases pinning settings-private/code-shared and the
`*`-does-not-over-match boundary; new SftpDataAdapter cases pinning the
mirror, the per-device remote path, that notes are NOT mirrored, and that
a mirror failure does not fail the write. Full suite green (1223 passed).
2026-07-14 05:24:26 +00:00
|
|
|
// #342 / #429 — a plugin's SETTINGS go per-device: `Plugin.saveData()` fires on
|
|
|
|
|
// every settings change, so a shared `plugins/<id>/data.json` is the same
|
|
|
|
|
// perpetual write-conflict the four core config files were redirected to
|
|
|
|
|
// escape. Its CODE must stay SHARED, or a plugin installed on one machine
|
|
|
|
|
// stops loading on the others (that round-trip is PLUGIN_BINARY_FILES).
|
|
|
|
|
describe('PathMapper — plugin settings vs plugin code (#342 / #429)', () => {
|
|
|
|
|
const m = new PathMapper(ID);
|
|
|
|
|
|
|
|
|
|
it('privatises a plugin data.json', () => {
|
|
|
|
|
expect(m.isPrivate('.obsidian/plugins/claudian/data.json')).toBe(true);
|
|
|
|
|
expect(m.isPrivate('.obsidian/plugins/tasknotes/data.json')).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('leaves the plugin code SHARED', () => {
|
|
|
|
|
expect(m.isPrivate('.obsidian/plugins/claudian/main.js')).toBe(false);
|
|
|
|
|
expect(m.isPrivate('.obsidian/plugins/claudian/manifest.json')).toBe(false);
|
|
|
|
|
expect(m.isPrivate('.obsidian/plugins/claudian/styles.css')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('does not over-match: `*` spans exactly one segment', () => {
|
|
|
|
|
expect(m.isPrivate('.obsidian/plugins')).toBe(false);
|
|
|
|
|
expect(m.isPrivate('.obsidian/plugins/claudian')).toBe(false);
|
|
|
|
|
expect(m.isPrivate('.obsidian/plugins/a/b/data.json')).toBe(false);
|
|
|
|
|
expect(m.isPrivate('.obsidian/data.json')).toBe(false);
|
|
|
|
|
expect(m.isPrivate('Notes/data.json')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('redirects data.json per-client, keeping code at the identity path', () => {
|
|
|
|
|
expect(m.toRemote('.obsidian/plugins/claudian/data.json'))
|
|
|
|
|
.toBe('.obsidian/user/host-a/plugins/claudian/data.json');
|
|
|
|
|
expect(m.toRemote('.obsidian/plugins/claudian/main.js'))
|
|
|
|
|
.toBe('.obsidian/plugins/claudian/main.js');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('round-trips through toVault', () => {
|
|
|
|
|
const remote = m.toRemote('.obsidian/plugins/claudian/data.json');
|
|
|
|
|
expect(m.toVault(remote)).toBe('.obsidian/plugins/claudian/data.json');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('listing a plugin dir merges in this client private subtree', () => {
|
|
|
|
|
const plan = m.resolveListing('.obsidian/plugins/claudian');
|
|
|
|
|
expect(plan.primary).toBe('.obsidian/plugins/claudian');
|
|
|
|
|
expect(plan.mergeFromUser).toBe(true);
|
|
|
|
|
expect(plan.userSubtree).toBe('.obsidian/user/host-a/plugins/claudian');
|
|
|
|
|
// The `user` sibling only exists at the configDir level, so nothing to
|
|
|
|
|
// hide at this depth.
|
|
|
|
|
expect(plan.hideUserDirName).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('still hides the user subdir when listing the configDir itself', () => {
|
|
|
|
|
const plan = m.resolveListing('.obsidian');
|
|
|
|
|
expect(plan.mergeFromUser).toBe(true);
|
|
|
|
|
expect(plan.userSubtree).toBe('.obsidian/user/host-a');
|
|
|
|
|
expect(plan.hideUserDirName).toBe('user');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat(plugin): per-client path mapping (PathMapper)
Vault-relative paths matching `.obsidian/workspace.json`, `.obsidian/
cache`, `.obsidian/types.json`, etc. are redirected into a
per-client subtree on the remote (`.obsidian/user/<client-id>/...`)
so two machines on the same vault don't trample each other's UI
state. The client id defaults to a sanitized OS hostname; multi-
instance setups can override it later when a settings field lands.
src/path/PathMapper.ts
- DEFAULT_PRIVATE_PATTERNS: workspace.json, workspace-mobile.json,
cache, cache.zlib, types.json, file-recovery.json, graph.json,
canvas.json. Errors on the side of "private" — the cost of an
unnecessary per-machine copy is low; the cost of a shared
workspace.json being clobbered is loud.
- isPrivate(path): exact match or directory-prefix match against
the patterns. Tolerates a leading slash on the input.
- isCrossingPoint(path): true when `path` is the parent of one or
more private patterns but not itself private. Today this matches
exactly `.obsidian` — listing it requires merging the shared
remote `.obsidian/` with the per-client subtree.
- toRemote / toVault: round-trip path translation. Foreign clients'
subtrees are preserved unchanged on the way back so the caller
can decide whether to filter them out.
- resolveListing(path): planning helper for `list()` — returns
{ primary, mergeFromUser, userSubtree?, hideUserDirName? }.
- defaultClientId() / sanitizeClientId(): hostname-based id with
unsafe characters replaced by hyphens; falls back to "unknown"
when sanitization yields an empty string.
src/adapter/SftpDataAdapter.ts
- Optional 6th constructor param `pathMapper`. When supplied,
`toRemote()` first runs the vault path through the mapper, then
joins with `remoteBasePath` as before. A new private `joinRemote`
exposes the join-only step for the listing planner.
- `list()` consults the mapper's `resolveListing(...)` plan: it
always lists the primary path (and prunes the `user/` directory
on a crossing-point listing), then optionally lists the user
subtree and merges the entries. User-subtree entries take
precedence on name conflicts so private files always show up
under their nominal `.obsidian/...` name.
- A small `planList(path)` shim exists so adapter tests can see
what the mapper decided without going through a real
RemoteFsClient.
src/main.ts
- `debugPatchAdapter` constructs a PathMapper with `defaultClientId`
and passes it to the adapter on every patch. The clientId is
logged so diagnostics in console.log show which subtree the
current session writes into.
Tests
- tests/PathMapper.test.ts (22 cases): isPrivate matching including
prefix/sibling distinction, isCrossingPoint, toRemote/toVault
round-trip, foreign-client subtree pass-through, resolveListing
for `.obsidian` (merge with hidden user/), private-dir
redirection, ordinary path pass-through, custom-pattern
override, and the sanitizeClientId helper for the canonical
cases (alphanumerics + dot/hyphen/underscore preserved, unsafe
characters folded, empty-after-sanitization → "unknown").
- tests/SftpDataAdapter.test.ts grew a "with PathMapper" describe
block (5 cases): writes to private paths land in the per-client
subtree, reads come back from there, non-private vault content is
not redirected, `.obsidian` listing merges shared + per-client
while hiding the user/ dir, and a private-directory listing
walks the per-client subtree.
Verification
- cd plugin && npx tsc --noEmit clean
- cd plugin && npm test 151 / 151 pass (28 new)
- cd plugin && npm run build:full all green; bundle 386 KB
(+1 KB over Phase 5-D.5).
Follow-ups
- Settings field for explicit clientId overrides (multi-instance
setups, anonymisation).
- Bootstrap: copy local `.obsidian/` skeleton into the per-client
subtree on first connect so Obsidian's first read of
workspace.json doesn't fail.
- fs.watch (Phase 5-E) and getResourcePath (Phase 5-F) are still
the next big features.
2026-04-25 09:07:13 +00:00
|
|
|
describe('PathMapper.isCrossingPoint', () => {
|
|
|
|
|
it('flags `.obsidian/` because every private pattern lives directly under it', () => {
|
|
|
|
|
const m = new PathMapper(ID);
|
|
|
|
|
expect(m.isCrossingPoint('.obsidian')).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('does not flag the private dirs themselves (those are private, not crossing)', () => {
|
|
|
|
|
const m = new PathMapper(ID);
|
|
|
|
|
expect(m.isCrossingPoint('.obsidian/cache')).toBe(false);
|
|
|
|
|
expect(m.isCrossingPoint('.obsidian/workspace.json')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('does not flag unrelated parents', () => {
|
|
|
|
|
const m = new PathMapper(ID);
|
|
|
|
|
expect(m.isCrossingPoint('Notes')).toBe(false);
|
|
|
|
|
expect(m.isCrossingPoint('')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('PathMapper.toRemote / toVault', () => {
|
|
|
|
|
const m = new PathMapper(ID);
|
|
|
|
|
|
|
|
|
|
it('redirects private files into the per-client subtree', () => {
|
|
|
|
|
expect(m.toRemote('.obsidian/workspace.json'))
|
|
|
|
|
.toBe('.obsidian/user/host-a/workspace.json');
|
|
|
|
|
expect(m.toRemote('.obsidian/cache/foo.bin'))
|
|
|
|
|
.toBe('.obsidian/user/host-a/cache/foo.bin');
|
2026-07-02 12:41:35 +00:00
|
|
|
expect(m.toRemote('.obsidian/app.json'))
|
|
|
|
|
.toBe('.obsidian/user/host-a/app.json');
|
feat(plugin): per-client path mapping (PathMapper)
Vault-relative paths matching `.obsidian/workspace.json`, `.obsidian/
cache`, `.obsidian/types.json`, etc. are redirected into a
per-client subtree on the remote (`.obsidian/user/<client-id>/...`)
so two machines on the same vault don't trample each other's UI
state. The client id defaults to a sanitized OS hostname; multi-
instance setups can override it later when a settings field lands.
src/path/PathMapper.ts
- DEFAULT_PRIVATE_PATTERNS: workspace.json, workspace-mobile.json,
cache, cache.zlib, types.json, file-recovery.json, graph.json,
canvas.json. Errors on the side of "private" — the cost of an
unnecessary per-machine copy is low; the cost of a shared
workspace.json being clobbered is loud.
- isPrivate(path): exact match or directory-prefix match against
the patterns. Tolerates a leading slash on the input.
- isCrossingPoint(path): true when `path` is the parent of one or
more private patterns but not itself private. Today this matches
exactly `.obsidian` — listing it requires merging the shared
remote `.obsidian/` with the per-client subtree.
- toRemote / toVault: round-trip path translation. Foreign clients'
subtrees are preserved unchanged on the way back so the caller
can decide whether to filter them out.
- resolveListing(path): planning helper for `list()` — returns
{ primary, mergeFromUser, userSubtree?, hideUserDirName? }.
- defaultClientId() / sanitizeClientId(): hostname-based id with
unsafe characters replaced by hyphens; falls back to "unknown"
when sanitization yields an empty string.
src/adapter/SftpDataAdapter.ts
- Optional 6th constructor param `pathMapper`. When supplied,
`toRemote()` first runs the vault path through the mapper, then
joins with `remoteBasePath` as before. A new private `joinRemote`
exposes the join-only step for the listing planner.
- `list()` consults the mapper's `resolveListing(...)` plan: it
always lists the primary path (and prunes the `user/` directory
on a crossing-point listing), then optionally lists the user
subtree and merges the entries. User-subtree entries take
precedence on name conflicts so private files always show up
under their nominal `.obsidian/...` name.
- A small `planList(path)` shim exists so adapter tests can see
what the mapper decided without going through a real
RemoteFsClient.
src/main.ts
- `debugPatchAdapter` constructs a PathMapper with `defaultClientId`
and passes it to the adapter on every patch. The clientId is
logged so diagnostics in console.log show which subtree the
current session writes into.
Tests
- tests/PathMapper.test.ts (22 cases): isPrivate matching including
prefix/sibling distinction, isCrossingPoint, toRemote/toVault
round-trip, foreign-client subtree pass-through, resolveListing
for `.obsidian` (merge with hidden user/), private-dir
redirection, ordinary path pass-through, custom-pattern
override, and the sanitizeClientId helper for the canonical
cases (alphanumerics + dot/hyphen/underscore preserved, unsafe
characters folded, empty-after-sanitization → "unknown").
- tests/SftpDataAdapter.test.ts grew a "with PathMapper" describe
block (5 cases): writes to private paths land in the per-client
subtree, reads come back from there, non-private vault content is
not redirected, `.obsidian` listing merges shared + per-client
while hiding the user/ dir, and a private-directory listing
walks the per-client subtree.
Verification
- cd plugin && npx tsc --noEmit clean
- cd plugin && npm test 151 / 151 pass (28 new)
- cd plugin && npm run build:full all green; bundle 386 KB
(+1 KB over Phase 5-D.5).
Follow-ups
- Settings field for explicit clientId overrides (multi-instance
setups, anonymisation).
- Bootstrap: copy local `.obsidian/` skeleton into the per-client
subtree on first connect so Obsidian's first read of
workspace.json doesn't fail.
- fs.watch (Phase 5-E) and getResourcePath (Phase 5-F) are still
the next big features.
2026-04-25 09:07:13 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('passes non-private paths through unchanged', () => {
|
|
|
|
|
expect(m.toRemote('Notes/foo.md')).toBe('Notes/foo.md');
|
2026-07-02 12:41:35 +00:00
|
|
|
expect(m.toRemote('.obsidian/community-plugins.json')).toBe('.obsidian/community-plugins.json');
|
feat(plugin): per-client path mapping (PathMapper)
Vault-relative paths matching `.obsidian/workspace.json`, `.obsidian/
cache`, `.obsidian/types.json`, etc. are redirected into a
per-client subtree on the remote (`.obsidian/user/<client-id>/...`)
so two machines on the same vault don't trample each other's UI
state. The client id defaults to a sanitized OS hostname; multi-
instance setups can override it later when a settings field lands.
src/path/PathMapper.ts
- DEFAULT_PRIVATE_PATTERNS: workspace.json, workspace-mobile.json,
cache, cache.zlib, types.json, file-recovery.json, graph.json,
canvas.json. Errors on the side of "private" — the cost of an
unnecessary per-machine copy is low; the cost of a shared
workspace.json being clobbered is loud.
- isPrivate(path): exact match or directory-prefix match against
the patterns. Tolerates a leading slash on the input.
- isCrossingPoint(path): true when `path` is the parent of one or
more private patterns but not itself private. Today this matches
exactly `.obsidian` — listing it requires merging the shared
remote `.obsidian/` with the per-client subtree.
- toRemote / toVault: round-trip path translation. Foreign clients'
subtrees are preserved unchanged on the way back so the caller
can decide whether to filter them out.
- resolveListing(path): planning helper for `list()` — returns
{ primary, mergeFromUser, userSubtree?, hideUserDirName? }.
- defaultClientId() / sanitizeClientId(): hostname-based id with
unsafe characters replaced by hyphens; falls back to "unknown"
when sanitization yields an empty string.
src/adapter/SftpDataAdapter.ts
- Optional 6th constructor param `pathMapper`. When supplied,
`toRemote()` first runs the vault path through the mapper, then
joins with `remoteBasePath` as before. A new private `joinRemote`
exposes the join-only step for the listing planner.
- `list()` consults the mapper's `resolveListing(...)` plan: it
always lists the primary path (and prunes the `user/` directory
on a crossing-point listing), then optionally lists the user
subtree and merges the entries. User-subtree entries take
precedence on name conflicts so private files always show up
under their nominal `.obsidian/...` name.
- A small `planList(path)` shim exists so adapter tests can see
what the mapper decided without going through a real
RemoteFsClient.
src/main.ts
- `debugPatchAdapter` constructs a PathMapper with `defaultClientId`
and passes it to the adapter on every patch. The clientId is
logged so diagnostics in console.log show which subtree the
current session writes into.
Tests
- tests/PathMapper.test.ts (22 cases): isPrivate matching including
prefix/sibling distinction, isCrossingPoint, toRemote/toVault
round-trip, foreign-client subtree pass-through, resolveListing
for `.obsidian` (merge with hidden user/), private-dir
redirection, ordinary path pass-through, custom-pattern
override, and the sanitizeClientId helper for the canonical
cases (alphanumerics + dot/hyphen/underscore preserved, unsafe
characters folded, empty-after-sanitization → "unknown").
- tests/SftpDataAdapter.test.ts grew a "with PathMapper" describe
block (5 cases): writes to private paths land in the per-client
subtree, reads come back from there, non-private vault content is
not redirected, `.obsidian` listing merges shared + per-client
while hiding the user/ dir, and a private-directory listing
walks the per-client subtree.
Verification
- cd plugin && npx tsc --noEmit clean
- cd plugin && npm test 151 / 151 pass (28 new)
- cd plugin && npm run build:full all green; bundle 386 KB
(+1 KB over Phase 5-D.5).
Follow-ups
- Settings field for explicit clientId overrides (multi-instance
setups, anonymisation).
- Bootstrap: copy local `.obsidian/` skeleton into the per-client
subtree on first connect so Obsidian's first read of
workspace.json doesn't fail.
- fs.watch (Phase 5-E) and getResourcePath (Phase 5-F) are still
the next big features.
2026-04-25 09:07:13 +00:00
|
|
|
expect(m.toRemote('.obsidian')).toBe('.obsidian');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('toVault inverts a redirected path back to its vault-relative form', () => {
|
|
|
|
|
expect(m.toVault('.obsidian/user/host-a/workspace.json'))
|
|
|
|
|
.toBe('.obsidian/workspace.json');
|
|
|
|
|
expect(m.toVault('.obsidian/user/host-a/cache/foo.bin'))
|
|
|
|
|
.toBe('.obsidian/cache/foo.bin');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('leaves another client\'s subtree alone (so the caller can filter it out)', () => {
|
|
|
|
|
expect(m.toVault('.obsidian/user/host-b/workspace.json'))
|
|
|
|
|
.toBe('.obsidian/user/host-b/workspace.json');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('uses the configured client id for the redirect prefix', () => {
|
|
|
|
|
const other = new PathMapper('SomeBox');
|
|
|
|
|
expect(other.toRemote('.obsidian/workspace.json'))
|
|
|
|
|
.toBe('.obsidian/user/SomeBox/workspace.json');
|
|
|
|
|
});
|
fix(review): address /review-pr findings on #450 — preSpawnPull bypass + dotfile leak + misleading Notice
6-dimension multi-agent review surfaced one CRITICAL and several correctness/
clarity issues in the per-device-config + hide-dotfiles change:
CRITICAL — preSpawnPull bypassed PathMapper (main.ts). The pre-spawn config pull
built `toRemote` by joining the remote base only, so it read the OLD shared
identity path `<configDir>/app.json` and clobbered the freshly-redirected
per-device config on EVERY shadow-window spawn — silently undoing the fix. Now it
applies the same `PathMapper.toRemote` the shadow's adapter uses (from
`resolveClientId(settings)` + configDir), so the four config files pull from this
client's `user/<id>/` subtree; identity for non-private paths keeps
community-plugins.json / plugin binaries shared, unchanged.
MEDIUM — cross-client leak + configDir hardcode (BulkWalker). The dotfile filter
excepted a hard-coded `.obsidian`, which (a) drifted from the configurable
`app.vault.configDir` and (b) let every other client's `.obsidian/user/<id>/*`
private state (incl the 4 newly-redirected files) materialize as editable files
in the File Explorer. Dropped the exception — config is loaded off the local
shadow disk, never from the walked model, so hiding the whole `.obsidian` subtree
costs nothing and isolates foreign clients. `isHiddenPath` simplifies to the
codebase's `startsWith('.')` / `.some()` idiom.
HIGH — misleading "0 files" Notice (main.ts + BulkWalker). An all-dotfile remote
filtered to zero entries and fired "0 files found — check remotePath", the wrong
remediation. BulkWalkResult now carries `hiddenCount`; populate distinguishes
"0 visible, N hidden" with an accurate message and logs the count.
LOW — user-facing Notices + a comment said "shared-config" for files that are now
per-device; corrected. (Constant/method rename of SHARED_OBSIDIAN_CONFIG_FILES,
and a legacy→per-client one-time settings migration, are deferred — see PR notes.)
Tests: BulkWalker dotfile test updated (config dir now dropped) + fallback-path
filter test + hiddenCount assertions; PathMapper cross-machine isolation test;
stale restart-roundtrip comment corrected. tsc/lint clean; vitest 1206 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 13:10:37 +00:00
|
|
|
|
|
|
|
|
it('isolates per-device settings by client id (no cross-machine clobber)', () => {
|
|
|
|
|
// The whole point of making app.json per-device: two machines writing
|
|
|
|
|
// "the same" config file land on DIFFERENT remote paths, so neither can
|
|
|
|
|
// trip the other's write-precondition (the perpetual conflict fixed here).
|
|
|
|
|
const a = new PathMapper('host-a');
|
|
|
|
|
const b = new PathMapper('host-b');
|
|
|
|
|
expect(a.toRemote('.obsidian/app.json')).toBe('.obsidian/user/host-a/app.json');
|
|
|
|
|
expect(b.toRemote('.obsidian/app.json')).toBe('.obsidian/user/host-b/app.json');
|
|
|
|
|
expect(a.toRemote('.obsidian/app.json')).not.toBe(b.toRemote('.obsidian/app.json'));
|
|
|
|
|
});
|
feat(plugin): per-client path mapping (PathMapper)
Vault-relative paths matching `.obsidian/workspace.json`, `.obsidian/
cache`, `.obsidian/types.json`, etc. are redirected into a
per-client subtree on the remote (`.obsidian/user/<client-id>/...`)
so two machines on the same vault don't trample each other's UI
state. The client id defaults to a sanitized OS hostname; multi-
instance setups can override it later when a settings field lands.
src/path/PathMapper.ts
- DEFAULT_PRIVATE_PATTERNS: workspace.json, workspace-mobile.json,
cache, cache.zlib, types.json, file-recovery.json, graph.json,
canvas.json. Errors on the side of "private" — the cost of an
unnecessary per-machine copy is low; the cost of a shared
workspace.json being clobbered is loud.
- isPrivate(path): exact match or directory-prefix match against
the patterns. Tolerates a leading slash on the input.
- isCrossingPoint(path): true when `path` is the parent of one or
more private patterns but not itself private. Today this matches
exactly `.obsidian` — listing it requires merging the shared
remote `.obsidian/` with the per-client subtree.
- toRemote / toVault: round-trip path translation. Foreign clients'
subtrees are preserved unchanged on the way back so the caller
can decide whether to filter them out.
- resolveListing(path): planning helper for `list()` — returns
{ primary, mergeFromUser, userSubtree?, hideUserDirName? }.
- defaultClientId() / sanitizeClientId(): hostname-based id with
unsafe characters replaced by hyphens; falls back to "unknown"
when sanitization yields an empty string.
src/adapter/SftpDataAdapter.ts
- Optional 6th constructor param `pathMapper`. When supplied,
`toRemote()` first runs the vault path through the mapper, then
joins with `remoteBasePath` as before. A new private `joinRemote`
exposes the join-only step for the listing planner.
- `list()` consults the mapper's `resolveListing(...)` plan: it
always lists the primary path (and prunes the `user/` directory
on a crossing-point listing), then optionally lists the user
subtree and merges the entries. User-subtree entries take
precedence on name conflicts so private files always show up
under their nominal `.obsidian/...` name.
- A small `planList(path)` shim exists so adapter tests can see
what the mapper decided without going through a real
RemoteFsClient.
src/main.ts
- `debugPatchAdapter` constructs a PathMapper with `defaultClientId`
and passes it to the adapter on every patch. The clientId is
logged so diagnostics in console.log show which subtree the
current session writes into.
Tests
- tests/PathMapper.test.ts (22 cases): isPrivate matching including
prefix/sibling distinction, isCrossingPoint, toRemote/toVault
round-trip, foreign-client subtree pass-through, resolveListing
for `.obsidian` (merge with hidden user/), private-dir
redirection, ordinary path pass-through, custom-pattern
override, and the sanitizeClientId helper for the canonical
cases (alphanumerics + dot/hyphen/underscore preserved, unsafe
characters folded, empty-after-sanitization → "unknown").
- tests/SftpDataAdapter.test.ts grew a "with PathMapper" describe
block (5 cases): writes to private paths land in the per-client
subtree, reads come back from there, non-private vault content is
not redirected, `.obsidian` listing merges shared + per-client
while hiding the user/ dir, and a private-directory listing
walks the per-client subtree.
Verification
- cd plugin && npx tsc --noEmit clean
- cd plugin && npm test 151 / 151 pass (28 new)
- cd plugin && npm run build:full all green; bundle 386 KB
(+1 KB over Phase 5-D.5).
Follow-ups
- Settings field for explicit clientId overrides (multi-instance
setups, anonymisation).
- Bootstrap: copy local `.obsidian/` skeleton into the per-client
subtree on first connect so Obsidian's first read of
workspace.json doesn't fail.
- fs.watch (Phase 5-E) and getResourcePath (Phase 5-F) are still
the next big features.
2026-04-25 09:07:13 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('PathMapper.resolveListing', () => {
|
|
|
|
|
const m = new PathMapper(ID);
|
|
|
|
|
|
|
|
|
|
it('asks the caller to merge `.obsidian` with the user subtree, hiding the user/ dir', () => {
|
|
|
|
|
const r = m.resolveListing('.obsidian');
|
|
|
|
|
expect(r.primary).toBe('.obsidian');
|
|
|
|
|
expect(r.mergeFromUser).toBe(true);
|
|
|
|
|
expect(r.userSubtree).toBe('.obsidian/user/host-a');
|
|
|
|
|
expect(r.hideUserDirName).toBe('user');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('redirects a list of a private directory entirely', () => {
|
|
|
|
|
const r = m.resolveListing('.obsidian/cache');
|
|
|
|
|
expect(r.primary).toBe('.obsidian/user/host-a/cache');
|
|
|
|
|
expect(r.mergeFromUser).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('passes ordinary listings through', () => {
|
|
|
|
|
const r = m.resolveListing('Notes');
|
|
|
|
|
expect(r.primary).toBe('Notes');
|
|
|
|
|
expect(r.mergeFromUser).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('passes `.obsidian/plugins` through unmerged (not a crossing point under default patterns)', () => {
|
|
|
|
|
const r = m.resolveListing('.obsidian/plugins');
|
|
|
|
|
expect(r.primary).toBe('.obsidian/plugins');
|
|
|
|
|
expect(r.mergeFromUser).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('PathMapper with custom patterns', () => {
|
|
|
|
|
it('respects caller-supplied private patterns instead of the defaults', () => {
|
|
|
|
|
// Patterns must live under .obsidian/ so they redirect cleanly into the
|
|
|
|
|
// per-client subtree; this test extends the list with a hypothetical
|
|
|
|
|
// graph-experimental.json that ships with a future Obsidian version.
|
|
|
|
|
const m = new PathMapper(ID, ['.obsidian/graph-experimental.json']);
|
|
|
|
|
expect(m.isPrivate('.obsidian/graph-experimental.json')).toBe(true);
|
|
|
|
|
expect(m.isPrivate('.obsidian/workspace.json')).toBe(false); // not in custom list
|
|
|
|
|
expect(m.toRemote('.obsidian/graph-experimental.json'))
|
|
|
|
|
.toBe('.obsidian/user/host-a/graph-experimental.json');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('exports a stable default pattern list', () => {
|
|
|
|
|
expect(DEFAULT_PRIVATE_PATTERNS).toContain('.obsidian/workspace.json');
|
|
|
|
|
expect(DEFAULT_PRIVATE_PATTERNS).toContain('.obsidian/cache');
|
|
|
|
|
});
|
|
|
|
|
});
|