Initial release of NeoGDSync v0.1.0

Lightweight Google Drive sync plugin for Obsidian.
- Path-based Drive index, conflict detection, versioning
- Smart/push/pull sync modes
- URI handler: obsidian://neogdsync?mode=smart|push|pull
- isDesktopOnly: false (iOS/Android compatible)
This commit is contained in:
LM 2026-04-13 01:44:39 +08:00
commit 830c2e8edb
14 changed files with 2482 additions and 0 deletions

13
.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
# User data — never commit
data.json
.neogdsync/
# Build artifacts
node_modules/
*.js.map
# Misc
.DS_Store
neogdsync-server.py
*.log
*.err

26
esbuild.config.mjs Normal file
View file

@ -0,0 +1,26 @@
import esbuild from 'esbuild';
import { existsSync } from 'fs';
const prod = process.argv[2] === 'production';
const ctx = await esbuild.context({
entryPoints: ['src/main.ts'],
bundle: true,
external: ['obsidian', 'electron', 'codemirror', '@codemirror/*', '@lezer/*'],
format: 'cjs',
target: 'es2018',
logLevel: 'info',
sourcemap: prod ? false : 'inline',
treeShaking: true,
outfile: 'main.js',
define: { 'process.env.NODE_ENV': prod ? '"production"' : '"development"' },
});
if (prod) {
await ctx.rebuild();
await ctx.dispose();
console.log('Build complete.');
} else {
await ctx.watch();
console.log('Watching…');
}

1080
main.js Normal file

File diff suppressed because it is too large Load diff

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "neogdsync",
"name": "NeoGDSync",
"version": "0.1.0",
"minAppVersion": "1.6.0",
"description": "Lightweight Google Drive sync — tiny data.json, path-based index, conflict detection, versioning.",
"author": "LM",
"authorUrl": "",
"isDesktopOnly": false
}

29
src/auth.ts Normal file
View file

@ -0,0 +1,29 @@
/** Auth module — token refresh via configurable proxy */
export const DEFAULT_PROXY_URL = 'https://ogd.richardxiong.com/api/access';
export interface AccessToken {
token: string;
expiresAt: number;
}
let cached: AccessToken | null = null;
export async function getAccessToken(refreshToken: string, proxyUrl: string = DEFAULT_PROXY_URL): Promise<string> {
if (cached && Date.now() < cached.expiresAt - 60_000) {
return cached.token;
}
const resp = await fetch(proxyUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!resp.ok) throw new Error(`Auth failed: ${resp.status}`);
const { access_token, expires_in } = await resp.json();
cached = { token: access_token, expiresAt: Date.now() + expires_in * 1000 };
return cached.token;
}
export function clearTokenCache() {
cached = null;
}

197
src/driveApi.ts Normal file
View file

@ -0,0 +1,197 @@
/** Google Drive API v3 wrapper */
import { getAccessToken } from './auth';
import { DriveFileInfo } from './types';
const BASE = 'https://www.googleapis.com/drive/v3';
const UPLOAD = 'https://www.googleapis.com/upload/drive/v3';
const FOLDER_MIME = 'application/vnd.google-apps.folder';
async function req(
method: string,
url: string,
body?: BodyInit,
headers?: Record<string, string>,
refreshToken?: string,
): Promise<Response> {
const token = refreshToken ? await getAccessToken(refreshToken) : '';
const resp = await fetch(url, {
method,
headers: { Authorization: `Bearer ${token}`, ...headers },
body,
});
if (!resp.ok) {
const txt = await resp.text().catch(() => '');
throw new Error(`Drive ${method} ${url}${resp.status}: ${txt.slice(0, 200)}`);
}
return resp;
}
export class DriveApi {
constructor(private refreshToken: string) {}
private async fetch(method: string, url: string, body?: BodyInit, headers?: Record<string, string>) {
return req(method, url, body, headers, this.refreshToken);
}
// ── Folder operations ──────────────────────────────────────────
/** List direct children of a folder */
async listChildren(folderId: string): Promise<DriveFileInfo[]> {
const results: DriveFileInfo[] = [];
let pageToken: string | undefined;
do {
const params = new URLSearchParams({
q: `'${folderId}' in parents and trashed=false`,
fields: 'nextPageToken,files(id,name,mimeType,modifiedTime,size)',
pageSize: '1000',
});
if (pageToken) params.set('pageToken', pageToken);
const resp = await this.fetch('GET', `${BASE}/files?${params}`);
const data = await resp.json();
results.push(...(data.files ?? []));
pageToken = data.nextPageToken;
} while (pageToken);
return results;
}
/** Create a folder, return its Drive ID */
async createFolder(name: string, parentId: string): Promise<string> {
const resp = await this.fetch(
'POST',
`${BASE}/files?fields=id`,
JSON.stringify({ name, mimeType: FOLDER_MIME, parents: [parentId] }),
{ 'Content-Type': 'application/json' },
);
const { id } = await resp.json();
return id;
}
// ── File operations ────────────────────────────────────────────
/** Upload a new file (multipart). Returns Drive ID. */
async uploadFile(
name: string,
parentId: string,
content: ArrayBuffer,
mimeType: string,
modifiedTime: string,
keepRevision = false,
): Promise<string> {
const boundary = 'neogdsync_boundary';
const meta = JSON.stringify({ name, parents: [parentId], modifiedTime });
const body = buildMultipart(boundary, meta, content, mimeType);
const params = new URLSearchParams({ uploadType: 'multipart', fields: 'id' });
if (keepRevision) params.set('keepRevisionForever', 'true');
const resp = await this.fetch(
'POST',
`${UPLOAD}/files?${params}`,
body,
{ 'Content-Type': `multipart/related; boundary=${boundary}` },
);
const { id } = await resp.json();
return id;
}
/** Update existing file content. Returns Drive ID (unchanged). */
async updateFile(
driveId: string,
content: ArrayBuffer,
mimeType: string,
modifiedTime: string,
keepRevision = false,
): Promise<string> {
const boundary = 'neogdsync_boundary';
const meta = JSON.stringify({ modifiedTime });
const body = buildMultipart(boundary, meta, content, mimeType);
const params = new URLSearchParams({ uploadType: 'multipart', fields: 'id' });
if (keepRevision) params.set('keepRevisionForever', 'true');
const resp = await this.fetch(
'PATCH',
`${UPLOAD}/files/${driveId}?${params}`,
body,
{ 'Content-Type': `multipart/related; boundary=${boundary}` },
);
const { id } = await resp.json();
return id;
}
/** Rename a file on Drive */
async renameFile(driveId: string, newName: string): Promise<void> {
await this.fetch(
'PATCH',
`${BASE}/files/${driveId}?fields=id`,
JSON.stringify({ name: newName }),
{ 'Content-Type': 'application/json' },
);
}
/** Delete a file (move to trash) */
async deleteFile(driveId: string): Promise<void> {
await this.fetch('DELETE', `${BASE}/files/${driveId}`);
}
/** Download file content */
async downloadFile(driveId: string): Promise<ArrayBuffer> {
const resp = await this.fetch('GET', `${BASE}/files/${driveId}?alt=media`);
return resp.arrayBuffer();
}
/** Get file metadata */
async getFileMeta(driveId: string): Promise<DriveFileInfo> {
const resp = await this.fetch('GET', `${BASE}/files/${driveId}?fields=id,name,mimeType,modifiedTime,parents,size`);
return resp.json();
}
/** Get Drive changes since a token */
async getChanges(pageToken: string): Promise<{ changes: any[]; newToken: string }> {
const changes: any[] = [];
let token = pageToken;
while (token) {
const params = new URLSearchParams({
pageToken: token,
pageSize: '1000',
includeRemoved: 'true',
fields: 'nextPageToken,newStartPageToken,changes(fileId,removed,file(id,name,mimeType,modifiedTime))',
});
const resp = await this.fetch('GET', `${BASE}/changes?${params}`);
const data = await resp.json();
changes.push(...(data.changes ?? []));
token = data.nextPageToken ?? '';
if (data.newStartPageToken) {
return { changes, newToken: data.newStartPageToken };
}
}
return { changes, newToken: pageToken };
}
/** Get a fresh start page token */
async getStartPageToken(): Promise<string> {
const resp = await this.fetch('GET', `${BASE}/changes/startPageToken`);
const { startPageToken } = await resp.json();
return startPageToken;
}
/** List revisions of a file */
async listRevisions(driveId: string): Promise<any[]> {
const resp = await this.fetch('GET', `${BASE}/files/${driveId}/revisions?fields=revisions(id,modifiedTime,size)`);
const data = await resp.json();
return data.revisions ?? [];
}
}
// ── helpers ────────────────────────────────────────────────────
function buildMultipart(boundary: string, meta: string, content: ArrayBuffer, mime: string): Uint8Array {
const enc = new TextEncoder();
const header = enc.encode(
`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${meta}\r\n` +
`--${boundary}\r\nContent-Type: ${mime}\r\n\r\n`,
);
const footer = enc.encode(`\r\n--${boundary}--`);
const body = new Uint8Array(header.byteLength + content.byteLength + footer.byteLength);
body.set(header, 0);
body.set(new Uint8Array(content), header.byteLength);
body.set(footer, header.byteLength + content.byteLength);
return body;
}

432
src/main.ts Normal file
View file

@ -0,0 +1,432 @@
import {
Plugin, Notice, TFile, TFolder, TAbstractFile,
PluginSettingTab, App, Setting, Modal, normalizePath,
} from 'obsidian';
import { NeoSettings, DEFAULT_SETTINGS, PendingOps, ConflictRecord } from './types';
import { DriveApi } from './driveApi';
import { PathIndex } from './pathIndex';
import { VaultSnapshot } from './snapshot';
import { Syncer, SyncResult } from './syncer';
import { clearTokenCache } from './auth';
export default class NeoGDSync extends Plugin {
settings!: NeoSettings;
pendingOps: PendingOps = {};
conflicts: ConflictRecord[] = [];
drive!: DriveApi;
index!: PathIndex;
private snapshot!: VaultSnapshot;
private syncing = false;
private statusEl?: HTMLElement;
// ── Lifecycle ──────────────────────────────────────────────────
async onload() {
await this.loadSettings();
this.drive = new DriveApi(this.settings.refreshToken);
this.index = new PathIndex(this.app, this.drive, this.settings.vaultRootId);
this.snapshot = new VaultSnapshot(this.app);
// Load index + snapshot
await this.index.load();
await this.snapshot.load();
// Startup diff — catch offline changes
this.mergeOfflineDiff();
// Register vault event listeners
this.registerEvents();
// Ribbon icon
const ribbonIcon = this.addRibbonIcon('cloud', 'NeoGDSync', () => this.openSyncModal());
ribbonIcon.addClass('neogdsync-ribbon');
// Status bar
this.statusEl = this.addStatusBarItem();
this.updateStatus();
// Commands
this.addCommand({
id: 'smart-sync',
name: 'Smart Sync (auto conflict detect)',
callback: () => this.runSync('smart'),
});
this.addCommand({
id: 'force-push',
name: 'Force Push (local → Drive)',
callback: () => this.runSync('push'),
});
this.addCommand({
id: 'force-pull',
name: 'Force Pull (Drive → local)',
callback: () => this.runSync('pull'),
});
this.addCommand({
id: 'rebuild-index',
name: 'Rebuild Drive index',
callback: () => this.rebuildIndex(),
});
this.addCommand({
id: 'show-conflicts',
name: 'Show conflicts',
callback: () => this.showConflicts(),
});
// Settings tab
this.addSettingTab(new NeoSettingsTab(this.app, this));
new Notice('NeoGDSync loaded ✓');
}
async onunload() {
await this.saveSettings();
await this.index.save();
}
// ── Vault events ───────────────────────────────────────────────
private registerEvents() {
this.registerEvent(this.app.vault.on('create', f => this.handleCreate(f)));
this.registerEvent(this.app.vault.on('modify', f => this.handleModify(f)));
this.registerEvent(this.app.vault.on('delete', f => this.handleDelete(f)));
this.registerEvent(this.app.vault.on('rename', (f, old) => this.handleRename(f, old)));
}
private exclude(path: string): boolean {
if (path.startsWith('.neogdsync')) return true;
if (path.startsWith('.smart-env')) return true;
if (path.startsWith('.smtcmp')) return true;
if (path.endsWith('.DS_Store')) return true;
return false;
}
private handleCreate(f: TAbstractFile) {
if (this.exclude(f.path)) return;
const cur = this.pendingOps[f.path];
if (cur === 'delete') {
this.pendingOps[f.path] = f instanceof TFile ? 'modify' : 'create';
} else if (!cur) {
this.pendingOps[f.path] = 'create';
}
this.updateStatus();
this.debouncedSave();
}
private handleModify(f: TAbstractFile) {
if (this.exclude(f.path) || !(f instanceof TFile)) return;
if (!this.pendingOps[f.path]) {
this.pendingOps[f.path] = 'modify';
}
this.updateStatus();
this.debouncedSave();
}
private handleDelete(f: TAbstractFile) {
if (this.exclude(f.path)) return;
if (this.pendingOps[f.path] === 'create') {
delete this.pendingOps[f.path];
} else {
this.pendingOps[f.path] = 'delete';
}
this.index.delete(f.path);
this.updateStatus();
this.debouncedSave();
}
private handleRename(f: TAbstractFile, oldPath: string) {
if (this.exclude(f.path) && this.exclude(oldPath)) return;
// Delete old path op, create new path op
if (this.pendingOps[oldPath] === 'create') {
delete this.pendingOps[oldPath];
this.pendingOps[f.path] = 'create';
} else {
this.pendingOps[oldPath] = 'delete';
this.pendingOps[f.path] = 'create';
}
this.index.rename(oldPath, f.path);
this.updateStatus();
this.debouncedSave();
}
private mergeOfflineDiff() {
const diff = this.snapshot.computeDiff(p => this.exclude(p));
let count = 0;
for (const [path, op] of Object.entries(diff)) {
if (!this.pendingOps[path]) {
this.pendingOps[path] = op;
count++;
}
}
if (count > 0) {
console.log(`[NeoGDSync] Startup diff: ${count} offline changes detected`);
}
}
// ── Sync ───────────────────────────────────────────────────────
async runSync(mode: 'smart' | 'push' | 'pull') {
if (this.syncing) { new Notice('Sync already in progress'); return; }
if (!this.settings.refreshToken) { new Notice('NeoGDSync: no refresh token configured'); return; }
this.syncing = true;
this.updateStatus('Syncing…');
const notice = new Notice(`NeoGDSync: ${mode} sync started…`, 0);
try {
const syncer = new Syncer(
this.app, this.drive, this.index, this.snapshot,
this.settings, this.pendingOps,
(msg: string) => { notice.setMessage(`NeoGDSync: ${msg}`); },
);
let result: SyncResult;
if (mode === 'push') result = await syncer.forcePush();
else if (mode === 'pull') result = await syncer.forcePull();
else result = await syncer.smartSync();
this.conflicts.push(...result.conflicts);
this.settings.lastSyncedAt = Date.now();
await this.saveSettings();
await this.index.save();
const summary = `${result.pushed.length}${result.pulled.length} 🗑${result.deleted.length}` +
(result.conflicts.length ? ` ⚠️${result.conflicts.length} conflicts` : '') +
(result.errors.length ? `${result.errors.length} errors` : '');
notice.setMessage(`NeoGDSync: done — ${summary}`);
setTimeout(() => notice.hide(), 4000);
if (result.errors.length) {
console.error('[NeoGDSync] Errors:', result.errors);
}
if (result.conflicts.length) {
new Notice(`⚠️ ${result.conflicts.length} conflict(s) detected — check NeoGDSync conflicts`, 6000);
}
} catch (e: any) {
notice.setMessage(`NeoGDSync: ERROR — ${e.message}`);
setTimeout(() => notice.hide(), 5000);
console.error('[NeoGDSync]', e);
} finally {
this.syncing = false;
this.updateStatus();
}
}
async rebuildIndex() {
if (this.syncing) { new Notice('Sync in progress'); return; }
this.syncing = true;
const notice = new Notice('NeoGDSync: rebuilding Drive index…', 0);
try {
await this.index.rebuild(msg => notice.setMessage(`NeoGDSync: ${msg}`));
notice.setMessage('NeoGDSync: index rebuilt ✓');
setTimeout(() => notice.hide(), 3000);
} catch (e: any) {
notice.setMessage(`NeoGDSync: rebuild failed — ${e.message}`);
setTimeout(() => notice.hide(), 5000);
} finally {
this.syncing = false;
this.updateStatus();
}
}
showConflicts() {
new ConflictModal(this.app, this.conflicts).open();
}
openSyncModal() {
new SyncModal(this.app, this).open();
}
// ── Status bar ─────────────────────────────────────────────────
updateStatus(override?: string) {
if (!this.statusEl) return;
if (override) { this.statusEl.setText(`${override}`); return; }
const n = Object.keys(this.pendingOps).length;
const c = this.conflicts.length;
let txt = n > 0 ? `${n} pending` : '☁ synced';
if (c > 0) txt += ` ⚠️${c}`;
this.statusEl.setText(txt);
}
// ── Settings ───────────────────────────────────────────────────
private saveTimer?: ReturnType<typeof setTimeout>;
debouncedSave() {
clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => this.saveSettings(), 500);
}
async loadSettings() {
const saved = await this.loadData();
this.settings = Object.assign({}, DEFAULT_SETTINGS, saved?.settings ?? {});
this.pendingOps = saved?.pendingOps ?? {};
this.conflicts = saved?.conflicts ?? [];
}
async saveSettings() {
await this.saveData({
settings: this.settings,
pendingOps: this.pendingOps,
conflicts: this.conflicts,
});
}
}
// ── Sync Modal ────────────────────────────────────────────────
class SyncModal extends Modal {
constructor(app: App, private plugin: NeoGDSync) { super(app); }
onOpen() {
const { contentEl } = this;
contentEl.createEl('h2', { text: 'NeoGDSync' });
const pending = Object.keys(this.plugin.pendingOps).length;
contentEl.createEl('p', { text: `Pending operations: ${pending}` });
if (pending > 0) {
const ul = contentEl.createEl('ul');
for (const [p, op] of Object.entries(this.plugin.pendingOps).slice(0, 20)) {
ul.createEl('li', { text: `${op}: ${p}` });
}
if (pending > 20) ul.createEl('li', { text: `… and ${pending - 20} more` });
}
const btnRow = contentEl.createDiv({ cls: 'neogdsync-btn-row' });
const smartBtn = btnRow.createEl('button', { text: '⚡ Smart Sync' });
smartBtn.onclick = () => { this.close(); this.plugin.runSync('smart'); };
const pushBtn = btnRow.createEl('button', { text: '↑ Force Push' });
pushBtn.onclick = () => { this.close(); this.plugin.runSync('push'); };
const pullBtn = btnRow.createEl('button', { text: '↓ Force Pull' });
pullBtn.onclick = () => { this.close(); this.plugin.runSync('pull'); };
if (this.plugin.conflicts.length > 0) {
const conflictBtn = btnRow.createEl('button', { text: `⚠️ ${this.plugin.conflicts.length} Conflicts` });
conflictBtn.onclick = () => { this.close(); this.plugin.showConflicts(); };
}
}
onClose() { this.contentEl.empty(); }
}
// ── Conflict Modal ─────────────────────────────────────────────
class ConflictModal extends Modal {
constructor(app: App, private conflicts: ConflictRecord[]) { super(app); }
onOpen() {
const { contentEl } = this;
contentEl.createEl('h2', { text: `Conflicts (${this.conflicts.length})` });
if (!this.conflicts.length) {
contentEl.createEl('p', { text: 'No conflicts.' });
return;
}
for (const c of this.conflicts) {
const div = contentEl.createDiv({ cls: 'neogdsync-conflict' });
div.createEl('strong', { text: c.localPath });
div.createEl('br');
div.createEl('small', {
text: `Local: ${new Date(c.localMtime).toLocaleString()} | Drive: ${new Date(c.driveMtime).toLocaleString()}`,
});
div.createEl('br');
div.createEl('small', { text: `Drive copy saved as: ${c.conflictCopyPath}` });
}
}
onClose() { this.contentEl.empty(); }
}
// ── Settings Tab ───────────────────────────────────────────────
class NeoSettingsTab extends PluginSettingTab {
constructor(app: App, private plugin: NeoGDSync) { super(app, plugin); }
display() {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'NeoGDSync Settings' });
new Setting(containerEl)
.setName('Refresh Token')
.setDesc('Google OAuth2 refresh token (from google-drive-sync plugin)')
.addText(t => t
.setPlaceholder('1//05o…')
.setValue(this.plugin.settings.refreshToken)
.onChange(async v => {
this.plugin.settings.refreshToken = v.trim();
this.plugin.drive = new DriveApi(v.trim());
clearTokenCache();
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Vault Root Folder ID')
.setDesc('Google Drive folder ID that is the root of this vault. Change requires plugin reload.')
.addText(t => {
t.inputEl.style.width = '340px';
t.inputEl.style.fontFamily = 'monospace';
t.setPlaceholder('1xGNFQGB…')
.setValue(this.plugin.settings.vaultRootId)
.onChange(async v => {
this.plugin.settings.vaultRootId = v.trim();
// Reinitialise index with new root
this.plugin.index = new PathIndex(
this.plugin.app,
this.plugin.drive,
v.trim(),
);
await this.plugin.index.load();
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName('Sync Mode')
.setDesc('Default sync mode when using ribbon icon')
.addDropdown(d => d
.addOption('smart', 'Smart (conflict detect)')
.addOption('push', 'Force Push')
.addOption('pull', 'Force Pull')
.setValue(this.plugin.settings.syncMode)
.onChange(async v => {
this.plugin.settings.syncMode = v as any;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Keep Revisions')
.setDesc('Keep file revisions on Drive (version history)')
.addToggle(t => t
.setValue(this.plugin.settings.keepRevisions)
.onChange(async v => {
this.plugin.settings.keepRevisions = v;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Pending ops')
.setDesc(`${Object.keys(this.plugin.pendingOps).length} files queued`)
.addButton(b => b.setButtonText('Clear all').onClick(async () => {
this.plugin.pendingOps = {};
await this.plugin.saveSettings();
this.plugin.updateStatus();
this.display();
}));
new Setting(containerEl)
.setName('Rebuild Drive Index')
.setDesc('Crawl Drive vault from root and rebuild local index.db')
.addButton(b => b.setButtonText('Rebuild').onClick(() => this.plugin.rebuildIndex()));
containerEl.createEl('h3', { text: 'Status' });
const stats = containerEl.createEl('p');
stats.setText(
`Last sync: ${this.plugin.settings.lastSyncedAt
? new Date(this.plugin.settings.lastSyncedAt).toLocaleString()
: 'never'} | ` +
`Conflicts: ${this.plugin.conflicts.length}`,
);
}
}

30
src/mime.ts Normal file
View file

@ -0,0 +1,30 @@
/** Minimal MIME type lookup by file extension */
const MAP: Record<string, string> = {
md: 'text/markdown',
txt: 'text/plain',
html: 'text/html',
css: 'text/css',
js: 'application/javascript',
ts: 'application/typescript',
json: 'application/json',
pdf: 'application/pdf',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
mp4: 'video/mp4',
mp3: 'audio/mpeg',
zip: 'application/zip',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
csv: 'text/csv',
};
export function fromPath(path: string): string {
const ext = path.split('.').pop()?.toLowerCase() ?? '';
return MAP[ext] ?? 'application/octet-stream';
}

190
src/pathIndex.ts Normal file
View file

@ -0,0 +1,190 @@
/**
* PathIndex lightweight localPathdriveId cache.
* Stored in .neogdsync/index.db (JSON), separate from data.json.
* On cache miss, navigates Drive by path hierarchy from vaultRootId.
*/
import { DriveApi } from './driveApi';
import { FileIndex, IndexEntry } from './types';
import { App, normalizePath } from 'obsidian';
const INDEX_PATH = '.neogdsync/index.db';
const FOLDER_MIME = 'application/vnd.google-apps.folder';
export class PathIndex {
private index: FileIndex = {};
private dirty = false;
constructor(
private app: App,
private drive: DriveApi,
private vaultRootId: string,
) {}
// ── Persistence ────────────────────────────────────────────────
async load(): Promise<void> {
try {
const raw = await this.app.vault.adapter.read(normalizePath(INDEX_PATH));
this.index = JSON.parse(raw);
} catch {
this.index = {};
}
}
async save(): Promise<void> {
if (!this.dirty) return;
await ensureDir(this.app, '.neogdsync');
await this.app.vault.adapter.write(normalizePath(INDEX_PATH), JSON.stringify(this.index, null, 2));
this.dirty = false;
}
// ── Core lookups ───────────────────────────────────────────────
get(localPath: string): IndexEntry | undefined {
return this.index[localPath];
}
set(localPath: string, entry: IndexEntry): void {
this.index[localPath] = entry;
this.dirty = true;
}
delete(localPath: string): void {
if (this.index[localPath]) {
delete this.index[localPath];
this.dirty = true;
}
}
rename(oldPath: string, newPath: string): void {
const entry = this.index[oldPath];
if (entry) {
this.index[newPath] = entry;
delete this.index[oldPath];
this.dirty = true;
}
}
allPaths(): string[] {
return Object.keys(this.index);
}
// ── Drive path navigation ──────────────────────────────────────
/**
* Resolve a local path to its Drive folder ID.
* Creates folders on Drive if they don't exist yet.
* Caches folder IDs in the index.
*/
async resolveParentFolder(localPath: string): Promise<string> {
const parts = localPath.split('/');
if (parts.length === 1) return this.vaultRootId;
const parentPath = parts.slice(0, -1).join('/');
return this.resolveFolder(parentPath);
}
async resolveFolder(localPath: string): Promise<string> {
const cached = this.index[localPath];
if (cached?.isFolder) return cached.driveId;
const parts = localPath.split('/');
let currentId = this.vaultRootId;
let builtPath = '';
for (const part of parts) {
builtPath = builtPath ? `${builtPath}/${part}` : part;
const cachedPart = this.index[builtPath];
if (cachedPart?.isFolder) {
currentId = cachedPart.driveId;
continue;
}
// Look for folder among children of current
const children = await this.drive.listChildren(currentId);
const found = children.find(c => c.name === part && c.mimeType === FOLDER_MIME);
if (found) {
this.set(builtPath, {
driveId: found.id,
driveMtime: found.modifiedTime,
syncedAt: Date.now(),
isFolder: true,
});
currentId = found.id;
} else {
// Create the folder
const newId = await this.drive.createFolder(part, currentId);
this.set(builtPath, {
driveId: newId,
driveMtime: new Date().toISOString(),
syncedAt: Date.now(),
isFolder: true,
});
currentId = newId;
}
}
return currentId;
}
/**
* Find a file on Drive by navigating from vaultRoot by path.
* Returns null if not found.
*/
async findOnDrive(localPath: string): Promise<string | null> {
const parts = localPath.split('/');
const fileName = parts[parts.length - 1];
try {
const parentId = await this.resolveParentFolder(localPath);
const children = await this.drive.listChildren(parentId);
// Prefer index-known ID to avoid duplicates
const cached = this.index[localPath];
if (cached && !cached.isFolder) {
const match = children.find(c => c.id === cached.driveId);
if (match) return match.id;
}
// Fall back to name match (pick most recently modified if duplicates)
const matches = children.filter(c => c.name === fileName && c.mimeType !== FOLDER_MIME);
if (!matches.length) return null;
matches.sort((a, b) => (a.modifiedTime > b.modifiedTime ? -1 : 1));
return matches[0].id;
} catch {
return null;
}
}
/**
* Rebuild the full index by crawling Drive from vaultRoot.
* Used for initial setup or repair.
*/
async rebuild(onProgress?: (msg: string) => void): Promise<void> {
this.index = {};
await this.crawl(this.vaultRootId, '', onProgress);
this.dirty = true;
await this.save();
}
private async crawl(folderId: string, prefix: string, onProgress?: (msg: string) => void): Promise<void> {
const children = await this.drive.listChildren(folderId);
for (const child of children) {
const path = prefix ? `${prefix}/${child.name}` : child.name;
const isFolder = child.mimeType === FOLDER_MIME;
this.index[path] = {
driveId: child.id,
driveMtime: child.modifiedTime,
syncedAt: Date.now(),
isFolder,
};
if (onProgress) onProgress(path);
if (isFolder) {
await this.crawl(child.id, path, onProgress);
}
}
}
}
// ── helpers ────────────────────────────────────────────────────
async function ensureDir(app: App, path: string): Promise<void> {
const norm = normalizePath(path);
const exists = await app.vault.adapter.exists(norm);
if (!exists) await app.vault.createFolder(norm);
}

74
src/snapshot.ts Normal file
View file

@ -0,0 +1,74 @@
/**
* Snapshot records vault state after each sync.
* Stored in .neogdsync/snapshot.json, separate from data.json.
* On startup, diff current vault against snapshot to catch offline changes.
*/
import { App, TFile, TFolder, normalizePath } from 'obsidian';
import { Snapshot, PendingOps } from './types';
const SNAPSHOT_PATH = '.neogdsync/snapshot.json';
export class VaultSnapshot {
private snapshot: Snapshot = {};
constructor(private app: App) {}
async load(): Promise<void> {
try {
const raw = await this.app.vault.adapter.read(normalizePath(SNAPSHOT_PATH));
this.snapshot = JSON.parse(raw);
} catch {
this.snapshot = {};
}
}
async save(exclude: (path: string) => boolean): Promise<void> {
const fresh: Snapshot = {};
const files = this.app.vault.getFiles();
for (const f of files) {
if (!exclude(f.path)) {
fresh[f.path] = { mtime: f.stat.mtime, size: f.stat.size };
}
}
this.snapshot = fresh;
await this.app.vault.adapter.write(
normalizePath(SNAPSHOT_PATH),
JSON.stringify(fresh),
);
}
/**
* Diff current vault against last snapshot.
* Returns ops that happened while plugin was offline.
*/
computeDiff(exclude: (path: string) => boolean): PendingOps {
const ops: PendingOps = {};
const currentFiles = this.app.vault.getFiles();
const currentPaths = new Set<string>();
for (const f of currentFiles) {
if (exclude(f.path)) continue;
currentPaths.add(f.path);
const snap = this.snapshot[f.path];
if (!snap) {
ops[f.path] = 'create';
} else if (f.stat.mtime > snap.mtime || f.stat.size !== snap.size) {
ops[f.path] = 'modify';
}
}
// Deleted files
for (const p of Object.keys(this.snapshot)) {
if (!currentPaths.has(p)) {
ops[p] = 'delete';
}
}
return ops;
}
get(path: string) {
return this.snapshot[path];
}
}

290
src/syncer.ts Normal file
View file

@ -0,0 +1,290 @@
/**
* Syncer core sync engine.
* Handles push, pull, smart sync, conflict detection.
*/
import { App, normalizePath, TFile, Notice } from 'obsidian';
import { DriveApi } from './driveApi';
import { PathIndex } from './pathIndex';
import { VaultSnapshot } from './snapshot';
import { NeoSettings, PendingOps, ConflictRecord } from './types';
import * as mime from './mime';
const FOLDER_MIME = 'application/vnd.google-apps.folder';
export interface SyncResult {
pushed: string[];
pulled: string[];
deleted: string[];
conflicts: ConflictRecord[];
errors: Array<{ path: string; error: string }>;
}
export class Syncer {
conflicts: ConflictRecord[] = [];
constructor(
private app: App,
private drive: DriveApi,
private index: PathIndex,
private snapshot: VaultSnapshot,
private settings: NeoSettings,
private pendingOps: PendingOps,
private onProgress: (msg: string) => void,
) {}
private exclude(path: string): boolean {
if (path.startsWith('.neogdsync/')) return true;
if (path.startsWith('.smart-env/')) return true;
if (path.startsWith('.smtcmp')) return true;
if (path.endsWith('.DS_Store')) return true;
if (path.includes('node_modules/')) return true;
if (path.startsWith('.git/')) return true;
if (path === '.neogdsync') return true;
// User-defined excludes
for (const pat of this.settings.excludePaths) {
if (matchGlob(pat, path)) return true;
}
return false;
}
// ── Smart Sync ─────────────────────────────────────────────────
async smartSync(): Promise<SyncResult> {
const result: SyncResult = { pushed: [], pulled: [], deleted: [], conflicts: [], errors: [] };
// Step 1: merge offline diff into pendingOps
this.onProgress('Scanning local changes…');
const offlineDiff = this.snapshot.computeDiff(p => this.exclude(p));
for (const [path, op] of Object.entries(offlineDiff)) {
if (!this.pendingOps[path]) {
this.pendingOps[path] = op;
}
}
// Step 2: fetch Drive changes since last sync
this.onProgress('Fetching Drive changes…');
const { changes, newToken } = await this.drive.getChanges(this.settings.changesToken);
const driveChanged = new Map<string, { removed: boolean; mtime?: string }>();
for (const c of changes) {
const entry = this.index.get(''); // we need reverse lookup: driveId → path
// Build reverse map from index
}
// Build driveId→path reverse map
const driveIdToPath = new Map<string, string>();
for (const p of this.index.allPaths()) {
const e = this.index.get(p);
if (e) driveIdToPath.set(e.driveId, p);
}
for (const c of changes) {
const localPath = driveIdToPath.get(c.fileId);
if (localPath) {
driveChanged.set(localPath, {
removed: c.removed,
mtime: c.file?.modifiedTime,
});
}
}
// Step 3: process pending ops (push direction)
const allOps = Object.entries(this.pendingOps);
let done = 0;
for (const [path, op] of allOps) {
this.onProgress(`[${++done}/${allOps.length}] ${op}: ${path}`);
if (this.exclude(path)) continue;
try {
if (op === 'delete') {
await this.handleDelete(path, result);
} else {
const driveChange = driveChanged.get(path);
if (driveChange && !driveChange.removed && driveChange.mtime) {
// Both sides changed — conflict
await this.handleConflict(path, driveChange.mtime, result);
} else {
await this.handlePush(path, op, result);
}
}
} catch (e: any) {
result.errors.push({ path, error: e.message });
}
}
// Step 4: pull new files from Drive (files on Drive not in local ops, newer than lastSyncedAt)
await this.pullNewFromDrive(driveChanged, result);
// Step 5: update token and snapshot
this.settings.changesToken = newToken;
this.settings.lastSyncedAt = Date.now();
await this.snapshot.save(p => this.exclude(p));
await this.index.save();
// Clear pushed/deleted ops
for (const p of [...result.pushed, ...result.deleted]) {
delete this.pendingOps[p];
}
return result;
}
// ── Force Push ─────────────────────────────────────────────────
async forcePush(): Promise<SyncResult> {
const result: SyncResult = { pushed: [], pulled: [], deleted: [], conflicts: [], errors: [] };
const ops = this.pendingOps;
const allOps = Object.entries(ops);
let done = 0;
for (const [path, op] of allOps) {
this.onProgress(`[${++done}/${allOps.length}] push: ${path}`);
if (this.exclude(path)) continue;
try {
if (op === 'delete') await this.handleDelete(path, result);
else await this.handlePush(path, op, result);
} catch (e: any) {
result.errors.push({ path, error: e.message });
}
}
this.settings.lastSyncedAt = Date.now();
await this.snapshot.save(p => this.exclude(p));
await this.index.save();
for (const p of [...result.pushed, ...result.deleted]) {
delete this.pendingOps[p];
}
return result;
}
// ── Force Pull ─────────────────────────────────────────────────
async forcePull(): Promise<SyncResult> {
const result: SyncResult = { pushed: [], pulled: [], deleted: [], conflicts: [], errors: [] };
this.onProgress('Rebuilding Drive index…');
await this.index.rebuild(msg => this.onProgress(`Crawling: ${msg}`));
const paths = this.index.allPaths();
let done = 0;
for (const path of paths) {
const entry = this.index.get(path);
if (!entry || entry.isFolder) continue;
this.onProgress(`[${++done}] pull: ${path}`);
try {
const bytes = await this.drive.downloadFile(entry.driveId);
await writeLocal(this.app, path, bytes);
result.pulled.push(path);
} catch (e: any) {
result.errors.push({ path, error: e.message });
}
}
this.settings.lastSyncedAt = Date.now();
await this.snapshot.save(p => this.exclude(p));
await this.index.save();
return result;
}
// ── Internal helpers ───────────────────────────────────────────
private async handlePush(path: string, op: 'create' | 'modify', result: SyncResult): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(normalizePath(path));
if (!file || !(file instanceof TFile)) return;
const bytes = await this.app.vault.readBinary(file);
const mtime = new Date(file.stat.mtime).toISOString();
const mimeType = mime.fromPath(path);
const cached = this.index.get(path);
if (cached && !cached.isFolder && op === 'modify') {
// Update existing
await this.drive.updateFile(cached.driveId, bytes, mimeType, mtime, this.settings.keepRevisions);
this.index.set(path, { ...cached, driveMtime: mtime, syncedAt: Date.now() });
} else {
// Upload new — resolve/create parent folder first
const parentId = await this.index.resolveParentFolder(path);
const driveId = await this.drive.uploadFile(
file.name, parentId, bytes, mimeType, mtime, this.settings.keepRevisions,
);
this.index.set(path, { driveId, driveMtime: mtime, syncedAt: Date.now(), isFolder: false });
}
result.pushed.push(path);
}
private async handleDelete(path: string, result: SyncResult): Promise<void> {
const cached = this.index.get(path);
if (cached) {
try { await this.drive.deleteFile(cached.driveId); } catch { /* already gone */ }
this.index.delete(path);
}
result.deleted.push(path);
}
private async handleConflict(path: string, driveMtime: string, result: SyncResult): Promise<void> {
// Pull Drive version as .conflict copy, keep local as-is
const entry = this.index.get(path);
if (!entry) return;
const bytes = await this.drive.downloadFile(entry.driveId);
const ext = path.includes('.') ? path.slice(path.lastIndexOf('.')) : '';
const base = ext ? path.slice(0, -ext.length) : path;
const conflictPath = `${base}.conflict${ext}`;
await writeLocal(this.app, conflictPath, bytes);
const localFile = this.app.vault.getAbstractFileByPath(normalizePath(path));
const localMtime = localFile instanceof TFile ? localFile.stat.mtime : 0;
result.conflicts.push({
localPath: path,
localMtime,
driveMtime,
conflictCopyPath: conflictPath,
detectedAt: Date.now(),
});
// Still push local version
await this.handlePush(path, 'modify', result);
}
private async pullNewFromDrive(
driveChanged: Map<string, { removed: boolean; mtime?: string }>,
result: SyncResult,
): Promise<void> {
// Find Drive-changed files not in local pendingOps
for (const [path, change] of driveChanged.entries()) {
if (this.exclude(path)) continue;
if (this.pendingOps[path]) continue; // handled in push phase
if (change.removed) {
// Drive deleted something local
const localFile = this.app.vault.getAbstractFileByPath(normalizePath(path));
if (localFile) {
await this.app.vault.trash(localFile, true);
this.index.delete(path);
result.deleted.push(path);
}
continue;
}
// Download updated file
const entry = this.index.get(path);
if (!entry || entry.isFolder) continue;
try {
const bytes = await this.drive.downloadFile(entry.driveId);
await writeLocal(this.app, path, bytes);
if (change.mtime) {
this.index.set(path, { ...entry, driveMtime: change.mtime, syncedAt: Date.now() });
}
result.pulled.push(path);
} catch (e: any) {
result.errors.push({ path, error: e.message });
}
}
}
}
// ── Local file write ───────────────────────────────────────────
async function writeLocal(app: App, path: string, bytes: ArrayBuffer): Promise<void> {
const norm = normalizePath(path);
const parts = path.split('/');
if (parts.length > 1) {
const dir = normalizePath(parts.slice(0, -1).join('/'));
if (!(await app.vault.adapter.exists(dir))) {
await app.vault.createFolder(dir);
}
}
const existing = app.vault.getAbstractFileByPath(norm);
if (existing instanceof TFile) {
await app.vault.modifyBinary(existing, bytes);
} else {
await app.vault.createBinary(norm, bytes);
}
}

73
src/types.ts Normal file
View file

@ -0,0 +1,73 @@
export interface NeoSettings {
refreshToken: string;
vaultRootId: string;
authProxyUrl: string; // OAuth2 proxy endpoint
lastSyncedAt: number;
changesToken: string;
syncMode: 'smart' | 'push' | 'pull';
keepRevisions: boolean;
excludePaths: string[]; // glob patterns to exclude
concurrency: number; // parallel upload limit
}
export const DEFAULT_SETTINGS: NeoSettings = {
refreshToken: '',
vaultRootId: '',
authProxyUrl: 'https://ogd.richardxiong.com/api/access',
lastSyncedAt: 0,
changesToken: '',
syncMode: 'smart',
keepRevisions: true,
excludePaths: [
'.smart-env/**',
'.smtcmp*',
'.git/**',
'**/.DS_Store',
'**/node_modules/**',
'.neogdsync/**',
],
concurrency: 6,
};
// Stored in .neogdsync/index.db — NOT in data.json
export interface IndexEntry {
driveId: string;
driveMtime: string; // ISO string from Drive
syncedAt: number; // local Date.now() when synced
isFolder: boolean;
}
export interface FileIndex {
[localPath: string]: IndexEntry;
}
// Stored in .neogdsync/snapshot.json — NOT in data.json
export interface SnapshotEntry {
mtime: number; // ms
size: number; // bytes
}
export interface Snapshot {
[localPath: string]: SnapshotEntry;
}
// In-memory only, cleared after each sync
export type OpType = 'create' | 'modify' | 'delete';
export interface PendingOps {
[localPath: string]: OpType;
}
export interface DriveFileInfo {
id: string;
name: string;
mimeType: string;
modifiedTime: string;
parents?: string[];
size?: string;
}
export interface ConflictRecord {
localPath: string;
localMtime: number;
driveMtime: string;
conflictCopyPath: string;
detectedAt: number;
}

20
styles.css Normal file
View file

@ -0,0 +1,20 @@
.neogdsync-btn-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
.neogdsync-btn-row button {
flex: 1;
min-width: 120px;
}
.neogdsync-conflict {
border-left: 3px solid var(--color-orange);
padding: 8px 12px;
margin-bottom: 8px;
background: var(--background-secondary);
border-radius: 4px;
}
.neogdsync-ribbon.is-active {
color: var(--color-green);
}

18
tsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES2018",
"allowImportingTsExtensions": true,
"moduleResolution": "bundler",
"importHelpers": true,
"strict": true,
"noImplicitAny": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"lib": ["ES2018", "DOM"]
},
"include": ["src/**/*.ts"]
}