mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 17:10:32 +00:00
PathMapper currently derives clientId from os.hostname(), which is fine for the single-machine case but unreadable when multiple clients share a remote vault. Surface two settings so users can pick a friendly identity per device: - clientId — overrides the per-client subtree name on the remote (.obsidian/user/<id>/...). Empty falls back to defaultClientId(). Inputs are sanitised before save so a typo can't produce an invalid path. Changing leaves the old subtree behind; a one-line description in the UI flags that. - userName — display-only for now; surfaces in connect notices and is reserved for the future multi-client presence file. Empty falls back to the OS username. Both fields live under a new "This device" section in the SettingsTab. main.ts reads them through a new resolveClientId() that's used at adapter-patch time. defaultUserName() helper added next to defaultClientId() in PathMapper.ts. Tests: 2 new sanity cases (defaultClientId is round-trip-safe under sanitize, defaultUserName is non-empty); 195 total green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
173 lines
6.3 KiB
TypeScript
173 lines
6.3 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
PathMapper,
|
|
sanitizeClientId,
|
|
defaultClientId,
|
|
defaultUserName,
|
|
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('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('');
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|