martinlegend_neogdsync/main.js
LM 830c2e8edb 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)
2026-04-13 01:44:39 +08:00

1080 lines
39 KiB
JavaScript

"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}`
);
}
};