mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 06:52:07 +00:00
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.
153 lines
5.8 KiB
TypeScript
153 lines
5.8 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { PathMapper, sanitizeClientId, DEFAULT_PRIVATE_PATTERNS } from '../src/path/PathMapper';
|
|
|
|
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('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);
|
|
});
|
|
|
|
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);
|
|
expect(m.isPrivate('.obsidian/hotkeys.json')).toBe(false);
|
|
expect(m.isPrivate('.obsidian/plugins/myplugin/data.json')).toBe(false);
|
|
});
|
|
|
|
it('tolerates a leading slash on the input', () => {
|
|
expect(m.isPrivate('/.obsidian/workspace.json')).toBe(true);
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
it('passes non-private paths through unchanged', () => {
|
|
expect(m.toRemote('Notes/foo.md')).toBe('Notes/foo.md');
|
|
expect(m.toRemote('.obsidian/hotkeys.json')).toBe('.obsidian/hotkeys.json');
|
|
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');
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|