sotashimozono_obsidian-remo.../plugin/tests/PathMapper.test.ts

255 lines
10 KiB
TypeScript
Raw Permalink Normal View History

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';
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');
});
});
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);
});
fix(config+vault): per-device Obsidian config (kills the perpetual write-conflict) + hide dotfiles Dogfooding follow-up to the lazy loading in this PR. Two real-machine problems: 1. `.obsidian` config kept raising write-conflicts, blocking work. Root cause (read from the code, not guessed): a conflict fires when the read cache's mtime ≠ the remote file's mtime (SftpDataAdapter.writeBuffer). The daemon compares mtime as UnixMilli on both sides, so a single device's sequential note edits can't perpetually conflict. What CAN: app.json / appearance.json / core-plugins.json / hotkeys.json were the only config files NOT redirected per-client — they sat at the shared identity path, so two sessions (or one device across reconnects) both wrote the SAME remote file and every settings save tripped PreconditionFailed on the other's mtime. Fix = make them per-device: add the four to DEFAULT_PRIVATE_PATTERN_BASENAMES so PathMapper redirects each into this client's `<configDir>/user/<id>/` subtree (exactly what workspace.json/graph/cache already do). No shared path → the conflict is impossible by construction. The existing shared-config round-trip keeps working — now on the per-client path, so each machine gets its own remote backup + cross-session persistence. This is the user's own ask: "config should be settable per accessing device". 2. `.julia` (and other dot-dirs) cluttered + slowed the tree. Now: - BulkWalker drops dot-prefixed entries from every walk result (full walk AND each lazy per-folder deepen), keeping only the vault config dir — matching Obsidian's own default of hiding dot-names. A dot-DIR hides its whole subtree, not just its row. - DEFAULT_WALK_IGNORE_DIRS gains `.julia .cargo .rustup .npm .conda .gem` so the daemon prunes these huge caches server-side (never walked/transferred), the perf half of "hide `.julia` by default". tsc / lint clean; vitest 1204 passed (+ per-device PathMapper assertions and a BulkWalker dotfile-filter test; config round-trip integration tests still green because seed-write and pull-read go through the same PathMapper). Ships beta.17. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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);
fix(config+vault): per-device Obsidian config (kills the perpetual write-conflict) + hide dotfiles Dogfooding follow-up to the lazy loading in this PR. Two real-machine problems: 1. `.obsidian` config kept raising write-conflicts, blocking work. Root cause (read from the code, not guessed): a conflict fires when the read cache's mtime ≠ the remote file's mtime (SftpDataAdapter.writeBuffer). The daemon compares mtime as UnixMilli on both sides, so a single device's sequential note edits can't perpetually conflict. What CAN: app.json / appearance.json / core-plugins.json / hotkeys.json were the only config files NOT redirected per-client — they sat at the shared identity path, so two sessions (or one device across reconnects) both wrote the SAME remote file and every settings save tripped PreconditionFailed on the other's mtime. Fix = make them per-device: add the four to DEFAULT_PRIVATE_PATTERN_BASENAMES so PathMapper redirects each into this client's `<configDir>/user/<id>/` subtree (exactly what workspace.json/graph/cache already do). No shared path → the conflict is impossible by construction. The existing shared-config round-trip keeps working — now on the per-client path, so each machine gets its own remote backup + cross-session persistence. This is the user's own ask: "config should be settable per accessing device". 2. `.julia` (and other dot-dirs) cluttered + slowed the tree. Now: - BulkWalker drops dot-prefixed entries from every walk result (full walk AND each lazy per-folder deepen), keeping only the vault config dir — matching Obsidian's own default of hiding dot-names. A dot-DIR hides its whole subtree, not just its row. - DEFAULT_WALK_IGNORE_DIRS gains `.julia .cargo .rustup .npm .conda .gem` so the daemon prunes these huge caches server-side (never walked/transferred), the perf half of "hide `.julia` by default". tsc / lint clean; vitest 1204 passed (+ per-device PathMapper assertions and a BulkWalker dotfile-filter test; config round-trip integration tests still green because seed-write and pull-read go through the same PathMapper). Ships beta.17. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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');
fix(config+vault): per-device Obsidian config (kills the perpetual write-conflict) + hide dotfiles Dogfooding follow-up to the lazy loading in this PR. Two real-machine problems: 1. `.obsidian` config kept raising write-conflicts, blocking work. Root cause (read from the code, not guessed): a conflict fires when the read cache's mtime ≠ the remote file's mtime (SftpDataAdapter.writeBuffer). The daemon compares mtime as UnixMilli on both sides, so a single device's sequential note edits can't perpetually conflict. What CAN: app.json / appearance.json / core-plugins.json / hotkeys.json were the only config files NOT redirected per-client — they sat at the shared identity path, so two sessions (or one device across reconnects) both wrote the SAME remote file and every settings save tripped PreconditionFailed on the other's mtime. Fix = make them per-device: add the four to DEFAULT_PRIVATE_PATTERN_BASENAMES so PathMapper redirects each into this client's `<configDir>/user/<id>/` subtree (exactly what workspace.json/graph/cache already do). No shared path → the conflict is impossible by construction. The existing shared-config round-trip keeps working — now on the per-client path, so each machine gets its own remote backup + cross-session persistence. This is the user's own ask: "config should be settable per accessing device". 2. `.julia` (and other dot-dirs) cluttered + slowed the tree. Now: - BulkWalker drops dot-prefixed entries from every walk result (full walk AND each lazy per-folder deepen), keeping only the vault config dir — matching Obsidian's own default of hiding dot-names. A dot-DIR hides its whole subtree, not just its row. - DEFAULT_WALK_IGNORE_DIRS gains `.julia .cargo .rustup .npm .conda .gem` so the daemon prunes these huge caches server-side (never walked/transferred), the perf half of "hide `.julia` by default". tsc / lint clean; vitest 1204 passed (+ per-device PathMapper assertions and a BulkWalker dotfile-filter test; config round-trip integration tests still green because seed-write and pull-read go through the same PathMapper). Ships beta.17. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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');
fix(config+vault): per-device Obsidian config (kills the perpetual write-conflict) + hide dotfiles Dogfooding follow-up to the lazy loading in this PR. Two real-machine problems: 1. `.obsidian` config kept raising write-conflicts, blocking work. Root cause (read from the code, not guessed): a conflict fires when the read cache's mtime ≠ the remote file's mtime (SftpDataAdapter.writeBuffer). The daemon compares mtime as UnixMilli on both sides, so a single device's sequential note edits can't perpetually conflict. What CAN: app.json / appearance.json / core-plugins.json / hotkeys.json were the only config files NOT redirected per-client — they sat at the shared identity path, so two sessions (or one device across reconnects) both wrote the SAME remote file and every settings save tripped PreconditionFailed on the other's mtime. Fix = make them per-device: add the four to DEFAULT_PRIVATE_PATTERN_BASENAMES so PathMapper redirects each into this client's `<configDir>/user/<id>/` subtree (exactly what workspace.json/graph/cache already do). No shared path → the conflict is impossible by construction. The existing shared-config round-trip keeps working — now on the per-client path, so each machine gets its own remote backup + cross-session persistence. This is the user's own ask: "config should be settable per accessing device". 2. `.julia` (and other dot-dirs) cluttered + slowed the tree. Now: - BulkWalker drops dot-prefixed entries from every walk result (full walk AND each lazy per-folder deepen), keeping only the vault config dir — matching Obsidian's own default of hiding dot-names. A dot-DIR hides its whole subtree, not just its row. - DEFAULT_WALK_IGNORE_DIRS gains `.julia .cargo .rustup .npm .conda .gem` so the daemon prunes these huge caches server-side (never walked/transferred), the perf half of "hide `.julia` by default". tsc / lint clean; vitest 1204 passed (+ per-device PathMapper assertions and a BulkWalker dotfile-filter test; config round-trip integration tests still green because seed-write and pull-read go through the same PathMapper). Ships beta.17. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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');
});
});