From 830c2e8edba0f2c8e6665ea60c5a3d1222b2d702 Mon Sep 17 00:00:00 2001 From: LM Date: Mon, 13 Apr 2026 01:44:39 +0800 Subject: [PATCH] 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) --- .gitignore | 13 + esbuild.config.mjs | 26 ++ main.js | 1080 ++++++++++++++++++++++++++++++++++++++++++++ manifest.json | 10 + src/auth.ts | 29 ++ src/driveApi.ts | 197 ++++++++ src/main.ts | 432 ++++++++++++++++++ src/mime.ts | 30 ++ src/pathIndex.ts | 190 ++++++++ src/snapshot.ts | 74 +++ src/syncer.ts | 290 ++++++++++++ src/types.ts | 73 +++ styles.css | 20 + tsconfig.json | 18 + 14 files changed, 2482 insertions(+) create mode 100644 .gitignore create mode 100644 esbuild.config.mjs create mode 100644 main.js create mode 100644 manifest.json create mode 100644 src/auth.ts create mode 100644 src/driveApi.ts create mode 100644 src/main.ts create mode 100644 src/mime.ts create mode 100644 src/pathIndex.ts create mode 100644 src/snapshot.ts create mode 100644 src/syncer.ts create mode 100644 src/types.ts create mode 100644 styles.css create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b27db80 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..2545edb --- /dev/null +++ b/esbuild.config.mjs @@ -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…'); +} diff --git a/main.js b/main.js new file mode 100644 index 0000000..8ed09ae --- /dev/null +++ b/main.js @@ -0,0 +1,1080 @@ +"use strict"; +function matchGlob(pattern, path) { + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\x00").replace(/\*/g, "[^/]*").replace(/\x00/g, ".*").replace(/\?/g, "[^/]"); + return new RegExp("^" + escaped + "$").test(path); +} +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/main.ts +var main_exports = {}; +__export(main_exports, { + default: () => NeoGDSync +}); +module.exports = __toCommonJS(main_exports); +var import_obsidian4 = require("obsidian"); + +// src/types.ts +var DEFAULT_SETTINGS = { + refreshToken: "", + vaultRootId: "", + lastSyncedAt: 0, + changesToken: "", + syncMode: "smart", + keepRevisions: true, + excludePaths: [ + ".smart-env/**", + ".smtcmp*", + ".git/**", + "**/.DS_Store", + "**/node_modules/**", + ".neogdsync/**" + ], + concurrency: 6 +}; + +// src/auth.ts +var PROXY_URL = "https://ogd.richardxiong.com/api/access"; +var cached = null; +async function getAccessToken(refreshToken) { + if (cached && Date.now() < cached.expiresAt - 6e4) { + return cached.token; + } + const resp = await fetch(PROXY_URL, { + 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 * 1e3 }; + return cached.token; +} +function clearTokenCache() { + cached = null; +} + +// src/driveApi.ts +var BASE = "https://www.googleapis.com/drive/v3"; +var UPLOAD = "https://www.googleapis.com/upload/drive/v3"; +var FOLDER_MIME = "application/vnd.google-apps.folder"; +async function req(method, url, body, headers, refreshToken) { + 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} \u2192 ${resp.status}: ${txt.slice(0, 200)}`); + } + return resp; +} +var DriveApi = class { + constructor(refreshToken) { + this.refreshToken = refreshToken; + } + async fetch(method, url, body, headers) { + return req(method, url, body, headers, this.refreshToken); + } + // ── Folder operations ────────────────────────────────────────── + /** List direct children of a folder */ + async listChildren(folderId) { + var _a; + const results = []; + let pageToken; + 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(...(_a = data.files) != null ? _a : []); + pageToken = data.nextPageToken; + } while (pageToken); + return results; + } + /** Create a folder, return its Drive ID */ + async createFolder(name, parentId) { + 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, parentId, content, mimeType, modifiedTime, keepRevision = false) { + 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, content, mimeType, modifiedTime, keepRevision = false) { + 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, newName) { + 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) { + await this.fetch("DELETE", `${BASE}/files/${driveId}`); + } + /** Download file content */ + async downloadFile(driveId) { + const resp = await this.fetch("GET", `${BASE}/files/${driveId}?alt=media`); + return resp.arrayBuffer(); + } + /** Get file metadata */ + async getFileMeta(driveId) { + 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) { + var _a, _b; + const changes = []; + 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(...(_a = data.changes) != null ? _a : []); + token = (_b = data.nextPageToken) != null ? _b : ""; + if (data.newStartPageToken) { + return { changes, newToken: data.newStartPageToken }; + } + } + return { changes, newToken: pageToken }; + } + /** Get a fresh start page token */ + async getStartPageToken() { + const resp = await this.fetch("GET", `${BASE}/changes/startPageToken`); + const { startPageToken } = await resp.json(); + return startPageToken; + } + /** List revisions of a file */ + async listRevisions(driveId) { + var _a; + const resp = await this.fetch("GET", `${BASE}/files/${driveId}/revisions?fields=revisions(id,modifiedTime,size)`); + const data = await resp.json(); + return (_a = data.revisions) != null ? _a : []; + } +}; +function buildMultipart(boundary, meta, content, mime) { + const enc = new TextEncoder(); + const header = enc.encode( + `--${boundary}\r +Content-Type: application/json; charset=UTF-8\r +\r +${meta}\r +--${boundary}\r +Content-Type: ${mime}\r +\r +` + ); + const footer = enc.encode(`\r +--${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; +} + +// src/pathIndex.ts +var import_obsidian = require("obsidian"); +var INDEX_PATH = ".neogdsync/index.db"; +var FOLDER_MIME2 = "application/vnd.google-apps.folder"; +var PathIndex = class { + constructor(app, drive, vaultRootId) { + this.app = app; + this.drive = drive; + this.vaultRootId = vaultRootId; + this.index = {}; + this.dirty = false; + } + // ── Persistence ──────────────────────────────────────────────── + async load() { + try { + const raw = await this.app.vault.adapter.read((0, import_obsidian.normalizePath)(INDEX_PATH)); + this.index = JSON.parse(raw); + } catch (e) { + this.index = {}; + } + } + async save() { + if (!this.dirty) return; + await ensureDir(this.app, ".neogdsync"); + await this.app.vault.adapter.write((0, import_obsidian.normalizePath)(INDEX_PATH), JSON.stringify(this.index, null, 2)); + this.dirty = false; + } + // ── Core lookups ─────────────────────────────────────────────── + get(localPath) { + return this.index[localPath]; + } + set(localPath, entry) { + this.index[localPath] = entry; + this.dirty = true; + } + delete(localPath) { + if (this.index[localPath]) { + delete this.index[localPath]; + this.dirty = true; + } + } + rename(oldPath, newPath) { + const entry = this.index[oldPath]; + if (entry) { + this.index[newPath] = entry; + delete this.index[oldPath]; + this.dirty = true; + } + } + allPaths() { + 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) { + 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) { + const cached2 = this.index[localPath]; + if (cached2 == null ? void 0 : cached2.isFolder) return cached2.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 == null ? void 0 : cachedPart.isFolder) { + currentId = cachedPart.driveId; + continue; + } + const children = await this.drive.listChildren(currentId); + const found = children.find((c) => c.name === part && c.mimeType === FOLDER_MIME2); + if (found) { + this.set(builtPath, { + driveId: found.id, + driveMtime: found.modifiedTime, + syncedAt: Date.now(), + isFolder: true + }); + currentId = found.id; + } else { + const newId = await this.drive.createFolder(part, currentId); + this.set(builtPath, { + driveId: newId, + driveMtime: (/* @__PURE__ */ 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) { + const parts = localPath.split("/"); + const fileName = parts[parts.length - 1]; + try { + const parentId = await this.resolveParentFolder(localPath); + const children = await this.drive.listChildren(parentId); + const cached2 = this.index[localPath]; + if (cached2 && !cached2.isFolder) { + const match = children.find((c) => c.id === cached2.driveId); + if (match) return match.id; + } + const matches = children.filter((c) => c.name === fileName && c.mimeType !== FOLDER_MIME2); + if (!matches.length) return null; + matches.sort((a, b) => a.modifiedTime > b.modifiedTime ? -1 : 1); + return matches[0].id; + } catch (e) { + return null; + } + } + /** + * Rebuild the full index by crawling Drive from vaultRoot. + * Used for initial setup or repair. + */ + async rebuild(onProgress) { + this.index = {}; + await this.crawl(this.vaultRootId, "", onProgress); + this.dirty = true; + await this.save(); + } + async crawl(folderId, prefix, onProgress) { + 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_MIME2; + 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); + } + } + } +}; +async function ensureDir(app, path) { + const norm = (0, import_obsidian.normalizePath)(path); + const exists = await app.vault.adapter.exists(norm); + if (!exists) await app.vault.createFolder(norm); +} + +// src/snapshot.ts +var import_obsidian2 = require("obsidian"); +var SNAPSHOT_PATH = ".neogdsync/snapshot.json"; +var VaultSnapshot = class { + constructor(app) { + this.app = app; + this.snapshot = {}; + } + async load() { + try { + const raw = await this.app.vault.adapter.read((0, import_obsidian2.normalizePath)(SNAPSHOT_PATH)); + this.snapshot = JSON.parse(raw); + } catch (e) { + this.snapshot = {}; + } + } + async save(exclude) { + const fresh = {}; + 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( + (0, import_obsidian2.normalizePath)(SNAPSHOT_PATH), + JSON.stringify(fresh) + ); + } + /** + * Diff current vault against last snapshot. + * Returns ops that happened while plugin was offline. + */ + computeDiff(exclude) { + const ops = {}; + const currentFiles = this.app.vault.getFiles(); + const currentPaths = /* @__PURE__ */ new Set(); + 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"; + } + } + for (const p of Object.keys(this.snapshot)) { + if (!currentPaths.has(p)) { + ops[p] = "delete"; + } + } + return ops; + } + get(path) { + return this.snapshot[path]; + } +}; + +// src/syncer.ts +var import_obsidian3 = require("obsidian"); + +// src/mime.ts +var MAP = { + 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" +}; +function fromPath(path) { + var _a, _b, _c; + const ext = (_b = (_a = path.split(".").pop()) == null ? void 0 : _a.toLowerCase()) != null ? _b : ""; + return (_c = MAP[ext]) != null ? _c : "application/octet-stream"; +} + +// src/syncer.ts +var Syncer = class { + constructor(app, drive, index, snapshot, settings, pendingOps, onProgress) { + this.app = app; + this.drive = drive; + this.index = index; + this.snapshot = snapshot; + this.settings = settings; + this.pendingOps = pendingOps; + this.onProgress = onProgress; + this.conflicts = []; + } + exclude(path) { + 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; + for (const pat of this.settings.excludePaths) { + if (matchGlob(pat, path)) return true; + } + return false; + } + // ── Smart Sync ───────────────────────────────────────────────── + async smartSync() { + var _a; + const result = { pushed: [], pulled: [], deleted: [], conflicts: [], errors: [] }; + this.onProgress("Scanning local changes\u2026"); + 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; + } + } + this.onProgress("Fetching Drive changes\u2026"); + const { changes, newToken } = await this.drive.getChanges(this.settings.changesToken); + const driveChanged = /* @__PURE__ */ new Map(); + for (const c of changes) { + const entry = this.index.get(""); + } + const driveIdToPath = /* @__PURE__ */ new Map(); + 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: (_a = c.file) == null ? void 0 : _a.modifiedTime + }); + } + } + 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) { + await this.handleConflict(path, driveChange.mtime, result); + } else { + await this.handlePush(path, op, result); + } + } + } catch (e) { + result.errors.push({ path, error: e.message }); + } + } + await this.pullNewFromDrive(driveChanged, result); + this.settings.changesToken = newToken; + 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 Push ───────────────────────────────────────────────── + async forcePush() { + const result = { 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) { + 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() { + const result = { pushed: [], pulled: [], deleted: [], conflicts: [], errors: [] }; + this.onProgress("Rebuilding Drive index\u2026"); + 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) { + 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 ─────────────────────────────────────────── + async handlePush(path, op, result) { + const file = this.app.vault.getAbstractFileByPath((0, import_obsidian3.normalizePath)(path)); + if (!file || !(file instanceof import_obsidian3.TFile)) return; + const bytes = await this.app.vault.readBinary(file); + const mtime = new Date(file.stat.mtime).toISOString(); + const mimeType = fromPath(path); + const cached2 = this.index.get(path); + if (cached2 && !cached2.isFolder && op === "modify") { + await this.drive.updateFile(cached2.driveId, bytes, mimeType, mtime, this.settings.keepRevisions); + this.index.set(path, { ...cached2, driveMtime: mtime, syncedAt: Date.now() }); + } else { + 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); + } + async handleDelete(path, result) { + const cached2 = this.index.get(path); + if (cached2) { + try { + await this.drive.deleteFile(cached2.driveId); + } catch (e) { + } + this.index.delete(path); + } + result.deleted.push(path); + } + async handleConflict(path, driveMtime, result) { + 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((0, import_obsidian3.normalizePath)(path)); + const localMtime = localFile instanceof import_obsidian3.TFile ? localFile.stat.mtime : 0; + result.conflicts.push({ + localPath: path, + localMtime, + driveMtime, + conflictCopyPath: conflictPath, + detectedAt: Date.now() + }); + await this.handlePush(path, "modify", result); + } + async pullNewFromDrive(driveChanged, result) { + for (const [path, change] of driveChanged.entries()) { + if (this.exclude(path)) continue; + if (this.pendingOps[path]) continue; + if (change.removed) { + const localFile = this.app.vault.getAbstractFileByPath((0, import_obsidian3.normalizePath)(path)); + if (localFile) { + await this.app.vault.trash(localFile, true); + this.index.delete(path); + result.deleted.push(path); + } + continue; + } + 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) { + result.errors.push({ path, error: e.message }); + } + } + } +}; +async function writeLocal(app, path, bytes) { + const norm = (0, import_obsidian3.normalizePath)(path); + const parts = path.split("/"); + if (parts.length > 1) { + const dir = (0, import_obsidian3.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 import_obsidian3.TFile) { + await app.vault.modifyBinary(existing, bytes); + } else { + await app.vault.createBinary(norm, bytes); + } +} + +// src/main.ts +var NeoGDSync = class extends import_obsidian4.Plugin { + constructor() { + super(...arguments); + this.pendingOps = {}; + this.conflicts = []; + this.syncing = false; + } + // ── 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); + await this.index.load(); + await this.snapshot.load(); + this.mergeOfflineDiff(); + this.registerEvents(); + const ribbonIcon = this.addRibbonIcon("cloud", "NeoGDSync", () => this.openSyncModal()); + ribbonIcon.addClass("neogdsync-ribbon"); + this.statusEl = this.addStatusBarItem(); + this.updateStatus(); + this.addCommand({ + id: "smart-sync", + name: "Smart Sync (auto conflict detect)", + callback: () => this.runSync("smart") + }); + this.addCommand({ + id: "force-push", + name: "Force Push (local \u2192 Drive)", + callback: () => this.runSync("push") + }); + this.addCommand({ + id: "force-pull", + name: "Force Pull (Drive \u2192 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() + }); + this.addSettingTab(new NeoSettingsTab(this.app, this)); + // ── URI handler: obsidian://neogdsync?mode=smart|push|pull + this.registerObsidianProtocolHandler("neogdsync", (params) => { + const mode = params.mode === "push" ? "push" : params.mode === "pull" ? "pull" : "smart"; + this.runSync(mode); + }); + new import_obsidian4.Notice("NeoGDSync loaded \u2713"); + } + async onunload() { + await this.saveSettings(); + await this.index.save(); + } + // ── Vault events ─────────────────────────────────────────────── + 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))); + } + exclude(path) { + 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; + } + handleCreate(f) { + if (this.exclude(f.path)) return; + const cur = this.pendingOps[f.path]; + if (cur === "delete") { + this.pendingOps[f.path] = f instanceof import_obsidian4.TFile ? "modify" : "create"; + } else if (!cur) { + this.pendingOps[f.path] = "create"; + } + this.updateStatus(); + this.debouncedSave(); + } + handleModify(f) { + if (this.exclude(f.path) || !(f instanceof import_obsidian4.TFile)) return; + if (!this.pendingOps[f.path]) { + this.pendingOps[f.path] = "modify"; + } + this.updateStatus(); + this.debouncedSave(); + } + handleDelete(f) { + 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(); + } + handleRename(f, oldPath) { + if (this.exclude(f.path) && this.exclude(oldPath)) return; + 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(); + } + 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) { + if (this.syncing) { + new import_obsidian4.Notice("Sync already in progress"); + return; + } + if (!this.settings.refreshToken) { + new import_obsidian4.Notice("NeoGDSync: no refresh token configured"); + return; + } + this.syncing = true; + this.updateStatus("Syncing\u2026"); + const notice = new import_obsidian4.Notice(`NeoGDSync: ${mode} sync started\u2026`, 0); + try { + const syncer = new Syncer( + this.app, + this.drive, + this.index, + this.snapshot, + this.settings, + this.pendingOps, + (msg) => { + notice.setMessage(`NeoGDSync: ${msg}`); + } + ); + let result; + 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 = `\u2191${result.pushed.length} \u2193${result.pulled.length} \u{1F5D1}${result.deleted.length}` + (result.conflicts.length ? ` \u26A0\uFE0F${result.conflicts.length} conflicts` : "") + (result.errors.length ? ` \u274C${result.errors.length} errors` : ""); + notice.setMessage(`NeoGDSync: done \u2014 ${summary}`); + setTimeout(() => notice.hide(), 4e3); + if (result.errors.length) { + console.error("[NeoGDSync] Errors:", result.errors); + } + if (result.conflicts.length) { + new import_obsidian4.Notice(`\u26A0\uFE0F ${result.conflicts.length} conflict(s) detected \u2014 check NeoGDSync conflicts`, 6e3); + } + } catch (e) { + notice.setMessage(`NeoGDSync: ERROR \u2014 ${e.message}`); + setTimeout(() => notice.hide(), 5e3); + console.error("[NeoGDSync]", e); + } finally { + this.syncing = false; + this.updateStatus(); + } + } + async rebuildIndex() { + if (this.syncing) { + new import_obsidian4.Notice("Sync in progress"); + return; + } + this.syncing = true; + const notice = new import_obsidian4.Notice("NeoGDSync: rebuilding Drive index\u2026", 0); + try { + await this.index.rebuild((msg) => notice.setMessage(`NeoGDSync: ${msg}`)); + notice.setMessage("NeoGDSync: index rebuilt \u2713"); + setTimeout(() => notice.hide(), 3e3); + } catch (e) { + notice.setMessage(`NeoGDSync: rebuild failed \u2014 ${e.message}`); + setTimeout(() => notice.hide(), 5e3); + } 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) { + if (!this.statusEl) return; + if (override) { + this.statusEl.setText(`\u2601 ${override}`); + return; + } + const n = Object.keys(this.pendingOps).length; + const c = this.conflicts.length; + let txt = n > 0 ? `\u2601 ${n} pending` : "\u2601 synced"; + if (c > 0) txt += ` \u26A0\uFE0F${c}`; + this.statusEl.setText(txt); + } + debouncedSave() { + clearTimeout(this.saveTimer); + this.saveTimer = setTimeout(() => this.saveSettings(), 500); + } + async loadSettings() { + var _a, _b, _c; + const saved = await this.loadData(); + this.settings = Object.assign({}, DEFAULT_SETTINGS, (_a = saved == null ? void 0 : saved.settings) != null ? _a : {}); + this.pendingOps = (_b = saved == null ? void 0 : saved.pendingOps) != null ? _b : {}; + this.conflicts = (_c = saved == null ? void 0 : saved.conflicts) != null ? _c : []; + } + async saveSettings() { + await this.saveData({ + settings: this.settings, + pendingOps: this.pendingOps, + conflicts: this.conflicts + }); + } +}; +var SyncModal = class extends import_obsidian4.Modal { + constructor(app, plugin) { + super(app); + this.plugin = plugin; + } + 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: `\u2026 and ${pending - 20} more` }); + } + const btnRow = contentEl.createDiv({ cls: "neogdsync-btn-row" }); + const smartBtn = btnRow.createEl("button", { text: "\u26A1 Smart Sync" }); + smartBtn.onclick = () => { + this.close(); + this.plugin.runSync("smart"); + }; + const pushBtn = btnRow.createEl("button", { text: "\u2191 Force Push" }); + pushBtn.onclick = () => { + this.close(); + this.plugin.runSync("push"); + }; + const pullBtn = btnRow.createEl("button", { text: "\u2193 Force Pull" }); + pullBtn.onclick = () => { + this.close(); + this.plugin.runSync("pull"); + }; + if (this.plugin.conflicts.length > 0) { + const conflictBtn = btnRow.createEl("button", { text: `\u26A0\uFE0F ${this.plugin.conflicts.length} Conflicts` }); + conflictBtn.onclick = () => { + this.close(); + this.plugin.showConflicts(); + }; + } + } + onClose() { + this.contentEl.empty(); + } +}; +var ConflictModal = class extends import_obsidian4.Modal { + constructor(app, conflicts) { + super(app); + this.conflicts = conflicts; + } + 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(); + } +}; +var NeoSettingsTab = class extends import_obsidian4.PluginSettingTab { + constructor(app, plugin) { + super(app, plugin); + this.plugin = plugin; + } + display() { + const { containerEl } = this; + containerEl.empty(); + containerEl.createEl("h2", { text: "NeoGDSync Settings" }); + new import_obsidian4.Setting(containerEl).setName("Refresh Token").setDesc("Google OAuth2 refresh token (from google-drive-sync plugin)").addText((t) => t.setPlaceholder("1//05o\u2026").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(); + })); + const vaultRootSetting = new import_obsidian4.Setting(containerEl).setName("Vault Root Folder ID").setDesc("Google Drive folder ID that is the root of this vault. Change requires plugin reload."); + vaultRootSetting.addText((t) => { + t.inputEl.style.width = "340px"; + t.inputEl.style.fontFamily = "monospace"; + t.setPlaceholder("1xGNFQGB\u2026").setValue(this.plugin.settings.vaultRootId).onChange(async (v) => { + this.plugin.settings.vaultRootId = v.trim(); + this.plugin.index = new PathIndex(this.plugin.app, this.plugin.drive, v.trim()); + await this.plugin.index.load(); + await this.plugin.saveSettings(); + }); + }); + new import_obsidian4.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; + await this.plugin.saveSettings(); + })); + new import_obsidian4.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 import_obsidian4.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 import_obsidian4.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}` + ); + } +}; diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..09bff31 --- /dev/null +++ b/manifest.json @@ -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 +} diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..4840e7b --- /dev/null +++ b/src/auth.ts @@ -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 { + 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; +} diff --git a/src/driveApi.ts b/src/driveApi.ts new file mode 100644 index 0000000..b097fd3 --- /dev/null +++ b/src/driveApi.ts @@ -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, + refreshToken?: string, +): Promise { + 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) { + return req(method, url, body, headers, this.refreshToken); + } + + // ── Folder operations ────────────────────────────────────────── + + /** List direct children of a folder */ + async listChildren(folderId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.fetch('DELETE', `${BASE}/files/${driveId}`); + } + + /** Download file content */ + async downloadFile(driveId: string): Promise { + const resp = await this.fetch('GET', `${BASE}/files/${driveId}?alt=media`); + return resp.arrayBuffer(); + } + + /** Get file metadata */ + async getFileMeta(driveId: string): Promise { + 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 { + 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 { + 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; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..6bdb907 --- /dev/null +++ b/src/main.ts @@ -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; + 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}`, + ); + } +} diff --git a/src/mime.ts b/src/mime.ts new file mode 100644 index 0000000..f046179 --- /dev/null +++ b/src/mime.ts @@ -0,0 +1,30 @@ +/** Minimal MIME type lookup by file extension */ + +const MAP: Record = { + 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'; +} diff --git a/src/pathIndex.ts b/src/pathIndex.ts new file mode 100644 index 0000000..2266acc --- /dev/null +++ b/src/pathIndex.ts @@ -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 { + try { + const raw = await this.app.vault.adapter.read(normalizePath(INDEX_PATH)); + this.index = JSON.parse(raw); + } catch { + this.index = {}; + } + } + + async save(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const norm = normalizePath(path); + const exists = await app.vault.adapter.exists(norm); + if (!exists) await app.vault.createFolder(norm); +} diff --git a/src/snapshot.ts b/src/snapshot.ts new file mode 100644 index 0000000..f540c4e --- /dev/null +++ b/src/snapshot.ts @@ -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 { + 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 { + 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(); + + 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]; + } +} diff --git a/src/syncer.ts b/src/syncer.ts new file mode 100644 index 0000000..f98d869 --- /dev/null +++ b/src/syncer.ts @@ -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 { + 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(); + 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(); + 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 { + 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 { + 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 { + 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 { + 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 { + // 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, + result: SyncResult, + ): Promise { + // 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 { + 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); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..4c2fcc1 --- /dev/null +++ b/src/types.ts @@ -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; +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..c04997a --- /dev/null +++ b/styles.css @@ -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); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6cd723f --- /dev/null +++ b/tsconfig.json @@ -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"] +}