mirror of
https://github.com/martinlegend/neogdsync.git
synced 2026-07-22 06:08:29 +00:00
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:
commit
830c2e8edb
14 changed files with 2482 additions and 0 deletions
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal 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
26
esbuild.config.mjs
Normal 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…');
|
||||
}
|
||||
10
manifest.json
Normal file
10
manifest.json
Normal 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
29
src/auth.ts
Normal 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
197
src/driveApi.ts
Normal 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
432
src/main.ts
Normal 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
30
src/mime.ts
Normal 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
190
src/pathIndex.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
/**
|
||||
* PathIndex — lightweight localPath→driveId 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
74
src/snapshot.ts
Normal 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
290
src/syncer.ts
Normal 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
73
src/types.ts
Normal 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
20
styles.css
Normal 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
18
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in a new issue