mirror of
https://github.com/martinlegend/neogdsync.git
synced 2026-07-22 06:08:29 +00:00
Correctness: - Edits made while a sync runs are no longer dropped. Vault event handlers now ignore only paths the sync itself wrote (Syncer.syncWrites) instead of suppressing all events behind a global flag, and pending ops are cleared per-op right after success instead of in a bulk sweep that also wiped ops re-queued mid-sync. - Renames now propagate to Drive: handlePush compares the Drive-side name and calls files.rename, so a local a.md -> b.md no longer leaves the old name on Drive (which later caused duplicate uploads after a rebuild). - A 'modify' op with no index entry (index lost/reset) looks the file up on Drive by path before uploading, preventing duplicate files on Drive. - Excluded paths queued in pendingOps are cleared instead of sticking in the "N pending" counter forever; exclusion logic is now shared between event handlers and the sync engine (src/exclude.ts), so user glob patterns apply consistently. computeDiff no longer reports newly-excluded snapshot entries as deletions. - Files first seen via unknown Drive changes (syncedAt=0) now go through conflict handling instead of silently overwriting the remote copy. - index.db is written via tmp-file + rename with recovery on load, so a crash mid-save can no longer silently reset the index. - Multipart upload boundary is randomized; a file containing the fixed boundary string no longer corrupts the upload. Auth / settings: - authProxyUrl is actually used now (it was silently ignored) and is editable in settings; DriveApi credentials update in place so PathIndex never holds a stale instance; token cache is keyed by token+proxy. - Exclude patterns are editable in settings (README advertised this but there was no UI). Performance / UX: - Force Pull and pull-new-from-Drive download with a small concurrency pool (settings.concurrency, previously dead config). - Unknown-change resolution fetches each file's metadata once instead of twice. - First run with a non-empty vault shows a notice explaining that an initial Force Push/Pull is required. Security / hygiene: - OAuth proxy escapes HTML in the callback pages (reflected XSS via ?error=...), and README now accurately describes that token refresh goes through the proxy and how to self-host it. - Removed stale compiled artifacts (src/*.js) and dead code (listRevisions); fixed package.json metadata (MIT, author, scripts). - Added vitest with unit tests for exclusion, snapshot diffing, and index persistence/recovery (17 tests). Claude-Session: https://claude.ai/code/session_011DimwV9zDr1ViCXRCZPxC9 Co-authored-by: Claude <noreply@anthropic.com>
86 lines
3.1 KiB
TypeScript
86 lines
3.1 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { App } from 'obsidian';
|
|
import { PathIndex } from '../src/pathIndex';
|
|
import { DriveApi } from '../src/driveApi';
|
|
|
|
/** In-memory DataAdapter covering what PathIndex uses. */
|
|
function makeApp(files: Map<string, string> = new Map()): { app: App; files: Map<string, string> } {
|
|
const adapter = {
|
|
exists: async (p: string) => files.has(p) || [...files.keys()].some(k => k.startsWith(p + '/')),
|
|
read: async (p: string) => {
|
|
if (!files.has(p)) throw new Error(`ENOENT: ${p}`);
|
|
return files.get(p) as string;
|
|
},
|
|
write: async (p: string, data: string) => { files.set(p, data); },
|
|
remove: async (p: string) => { files.delete(p); },
|
|
rename: async (from: string, to: string) => {
|
|
if (!files.has(from)) throw new Error(`ENOENT: ${from}`);
|
|
files.set(to, files.get(from) as string);
|
|
files.delete(from);
|
|
},
|
|
mkdir: async (p: string) => { files.set(p + '/.dir', ''); },
|
|
};
|
|
const app = { vault: { adapter, configDir: '.obsidian' } } as unknown as App;
|
|
return { app, files };
|
|
}
|
|
|
|
const fakeDrive = {} as DriveApi;
|
|
const entry = (driveId: string, isFolder = false) =>
|
|
({ driveId, driveMtime: '2026-01-01T00:00:00Z', syncedAt: 1, isFolder });
|
|
|
|
describe('PathIndex core ops', () => {
|
|
it('set/get/delete/rename/allPaths', () => {
|
|
const idx = new PathIndex(makeApp().app, fakeDrive, 'root');
|
|
idx.set('a/b.md', entry('id1'));
|
|
expect(idx.get('a/b.md')?.driveId).toBe('id1');
|
|
|
|
idx.rename('a/b.md', 'a/c.md');
|
|
expect(idx.get('a/b.md')).toBeUndefined();
|
|
expect(idx.get('a/c.md')?.driveId).toBe('id1');
|
|
|
|
expect(idx.allPaths()).toEqual(['a/c.md']);
|
|
idx.delete('a/c.md');
|
|
expect(idx.allPaths()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('PathIndex persistence', () => {
|
|
it('round-trips through save/load', async () => {
|
|
const { app, files } = makeApp();
|
|
const idx = new PathIndex(app, fakeDrive, 'root');
|
|
idx.set('n.md', entry('id9'));
|
|
await idx.save();
|
|
|
|
expect(files.has('.neogdsync/index.db')).toBe(true);
|
|
expect(files.has('.neogdsync/index.db.tmp')).toBe(false);
|
|
|
|
const idx2 = new PathIndex(app, fakeDrive, 'root');
|
|
await idx2.load();
|
|
expect(idx2.get('n.md')?.driveId).toBe('id9');
|
|
});
|
|
|
|
it('recovers from a crash that left only the .tmp file', async () => {
|
|
const { app, files } = makeApp();
|
|
files.set('.neogdsync/index.db.tmp', JSON.stringify({ 'x.md': entry('idX') }));
|
|
|
|
const idx = new PathIndex(app, fakeDrive, 'root');
|
|
await idx.load();
|
|
expect(idx.get('x.md')?.driveId).toBe('idX');
|
|
});
|
|
|
|
it('falls back to .tmp when the main index is corrupt', async () => {
|
|
const { app, files } = makeApp();
|
|
files.set('.neogdsync/index.db', '{not json');
|
|
files.set('.neogdsync/index.db.tmp', JSON.stringify({ 'y.md': entry('idY') }));
|
|
|
|
const idx = new PathIndex(app, fakeDrive, 'root');
|
|
await idx.load();
|
|
expect(idx.get('y.md')?.driveId).toBe('idY');
|
|
});
|
|
|
|
it('starts empty when nothing is readable', async () => {
|
|
const idx = new PathIndex(makeApp().app, fakeDrive, 'root');
|
|
await idx.load();
|
|
expect(idx.allPaths()).toEqual([]);
|
|
});
|
|
});
|