mirror of
https://github.com/martinlegend/neogdsync.git
synced 2026-07-22 06:08:29 +00:00
Fix: pendingOps flooding on every Obsidian restart (v0.1.2)
Root causes found and fixed: 1. mergeOfflineDiff() was called before onLayoutReady — vault.getFiles() returns stale/zero stats before layout ready, causing all 6000+ files to be flagged as modified on every startup. Moved to onLayoutReady. 2. onunload() called saveSettings() before snapshot.save(), so the fresh snapshot was never persisted to data.json. Fixed order. 3. Snapshot contained 261 .obsidian entries (from earlier versions). vault.getFiles() never returns .obsidian files, so all 261 were flagged as 'delete' on every restart. Fixed: added .obsidian to exclude(), purge in setRaw(), cleaned existing data.json. 4. Snapshot now stored in data.json (loadData/saveData) for reliability. Removed dependency on .neogdsync/snapshot.json file write. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e0f7b422ee
commit
f799e6cc26
4 changed files with 182 additions and 173 deletions
88
main.js
88
main.js
|
|
@ -395,21 +395,24 @@ async function ensureDir(app, path) {
|
|||
}
|
||||
}
|
||||
|
||||
// src/snapshot.ts
|
||||
// src/snapshot.ts — snapshot lives in memory, persisted via plugin.saveSettings()
|
||||
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 = {};
|
||||
// Called by plugin.loadSettings() to inject loaded snapshot
|
||||
setRaw(data) {
|
||||
const raw = data || {};
|
||||
// Purge any .obsidian entries that may have been saved in earlier versions
|
||||
for (const key of Object.keys(raw)) {
|
||||
if (key.startsWith(".obsidian")) delete raw[key];
|
||||
}
|
||||
this.snapshot = raw;
|
||||
}
|
||||
async load() {
|
||||
// no-op: data is injected via setRaw() from loadSettings()
|
||||
}
|
||||
async save(exclude) {
|
||||
const fresh = {};
|
||||
|
|
@ -420,14 +423,7 @@ var VaultSnapshot = class {
|
|||
}
|
||||
}
|
||||
this.snapshot = fresh;
|
||||
const snapDir = (0, import_obsidian2.normalizePath)(".neogdsync");
|
||||
if (!(await this.app.vault.adapter.exists(snapDir))) {
|
||||
await this.app.vault.adapter.mkdir(snapDir);
|
||||
}
|
||||
await this.app.vault.adapter.write(
|
||||
(0, import_obsidian2.normalizePath)(SNAPSHOT_PATH),
|
||||
JSON.stringify(fresh)
|
||||
);
|
||||
// Snapshot will be persisted on next saveSettings() call (called by runSync)
|
||||
}
|
||||
/**
|
||||
* Diff current vault against last snapshot.
|
||||
|
|
@ -443,7 +439,7 @@ var VaultSnapshot = class {
|
|||
const snap = this.snapshot[f.path];
|
||||
if (!snap) {
|
||||
ops[f.path] = "create";
|
||||
} else if (f.stat.mtime > snap.mtime || f.stat.size !== snap.size) {
|
||||
} else if ((f.stat.mtime - snap.mtime > 2000) || f.stat.size !== snap.size) {
|
||||
ops[f.path] = "modify";
|
||||
}
|
||||
}
|
||||
|
|
@ -752,14 +748,22 @@ var NeoGDSync = class extends import_obsidian4.Plugin {
|
|||
}
|
||||
// ── Lifecycle ──────────────────────────────────────────────────
|
||||
async onload() {
|
||||
this.snapshot = new VaultSnapshot(this.app);
|
||||
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();
|
||||
// snapshot already loaded via setRaw() in loadSettings()
|
||||
// Delay BOTH offline diff AND event registration until layout is ready.
|
||||
// Before onLayoutReady, vault.getFiles() returns stale/incorrect stat values
|
||||
// (mtime/size), which causes computeDiff to falsely flag all files as changed.
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
const filesBefore = this.app.vault.getFiles().length;
|
||||
console.log(`[NeoGDSync] onLayoutReady: vault has ${filesBefore} files`);
|
||||
this.mergeOfflineDiff();
|
||||
this.registerEvents();
|
||||
console.log("[NeoGDSync] Vault events registered (layout ready)");
|
||||
});
|
||||
const ribbonIcon = this.addRibbonIcon("cloud", "NeoGDSync", () => this.openSyncModal());
|
||||
ribbonIcon.addClass("neogdsync-ribbon");
|
||||
this.statusEl = this.addStatusBarItem();
|
||||
|
|
@ -798,9 +802,11 @@ var NeoGDSync = class extends import_obsidian4.Plugin {
|
|||
new import_obsidian4.Notice("NeoGDSync loaded \u2713");
|
||||
}
|
||||
async onunload() {
|
||||
// IMPORTANT: snapshot.save() MUST come before saveSettings()
|
||||
// so the fresh file stats are persisted to data.json
|
||||
await this.snapshot.save((p) => this.exclude(p));
|
||||
await this.saveSettings();
|
||||
await this.index.save();
|
||||
await this.snapshot.save((p) => this.exclude(p));
|
||||
}
|
||||
// ── Vault events ───────────────────────────────────────────────
|
||||
registerEvents() {
|
||||
|
|
@ -811,6 +817,7 @@ var NeoGDSync = class extends import_obsidian4.Plugin {
|
|||
}
|
||||
exclude(path) {
|
||||
if (path.startsWith(".neogdsync")) return true;
|
||||
if (path.startsWith(".obsidian")) return true;
|
||||
if (path.startsWith(".smart-env")) return true;
|
||||
if (path.startsWith(".smtcmp")) return true;
|
||||
if (path.endsWith(".DS_Store")) return true;
|
||||
|
|
@ -818,9 +825,10 @@ var NeoGDSync = class extends import_obsidian4.Plugin {
|
|||
}
|
||||
handleCreate(f) {
|
||||
if (this.exclude(f.path)) return;
|
||||
if (!(f instanceof import_obsidian4.TFile)) return; // skip folders
|
||||
const cur = this.pendingOps[f.path];
|
||||
if (cur === "delete") {
|
||||
this.pendingOps[f.path] = f instanceof import_obsidian4.TFile ? "modify" : "create";
|
||||
this.pendingOps[f.path] = "modify";
|
||||
} else if (!cur) {
|
||||
this.pendingOps[f.path] = "create";
|
||||
}
|
||||
|
|
@ -829,6 +837,9 @@ var NeoGDSync = class extends import_obsidian4.Plugin {
|
|||
}
|
||||
handleModify(f) {
|
||||
if (this.exclude(f.path) || !(f instanceof import_obsidian4.TFile)) return;
|
||||
// Skip if file matches snapshot (Obsidian fires modify events on startup for all files)
|
||||
const snap = this.snapshot.get(f.path);
|
||||
if (snap && Math.abs(f.stat.mtime - snap.mtime) <= 2000 && f.stat.size === snap.size) return;
|
||||
if (!this.pendingOps[f.path]) {
|
||||
this.pendingOps[f.path] = "modify";
|
||||
}
|
||||
|
|
@ -860,16 +871,32 @@ var NeoGDSync = class extends import_obsidian4.Plugin {
|
|||
this.debouncedSave();
|
||||
}
|
||||
mergeOfflineDiff() {
|
||||
const snapshotEmpty = Object.keys(this.snapshot.getAll ? this.snapshot.getAll() : {}).length === 0;
|
||||
if (snapshotEmpty) {
|
||||
const snapData = this.snapshot.getAll ? this.snapshot.getAll() : {};
|
||||
const snapCount = Object.keys(snapData).length;
|
||||
const vaultCount = this.app.vault.getFiles().length;
|
||||
console.log(`[NeoGDSync] mergeOfflineDiff: snapshot=${snapCount} entries, vault=${vaultCount} files`);
|
||||
if (snapCount === 0) {
|
||||
// No snapshot yet — save current vault state as baseline, don't flood pendingOps
|
||||
console.log("[NeoGDSync] No snapshot found — saving current vault as baseline, skipping offline diff");
|
||||
this.snapshot.save((p) => this.exclude(p)).catch(console.error);
|
||||
this.snapshot.save((p) => this.exclude(p)).then(() => this.saveSettings()).catch(console.error);
|
||||
return;
|
||||
}
|
||||
const diff = this.snapshot.computeDiff((p) => this.exclude(p));
|
||||
const diffEntries = Object.entries(diff);
|
||||
console.log(`[NeoGDSync] computeDiff returned ${diffEntries.length} ops`);
|
||||
// Log sample of flagged files to understand why they're being flagged
|
||||
const sample = diffEntries.slice(0, 5);
|
||||
for (const [path, op] of sample) {
|
||||
const f = this.app.vault.getAbstractFileByPath(path);
|
||||
const snap = snapData[path];
|
||||
if (f && snap && f.stat) {
|
||||
console.log(`[NeoGDSync] ${op}: ${path} | disk mtime=${f.stat.mtime} snap mtime=${snap.mtime} diff=${f.stat.mtime - snap.mtime}ms | disk size=${f.stat.size} snap size=${snap.size}`);
|
||||
} else {
|
||||
console.log(`[NeoGDSync] ${op}: ${path} | f=${!!f} snap=${!!snap}`);
|
||||
}
|
||||
}
|
||||
let count = 0;
|
||||
for (const [path, op] of Object.entries(diff)) {
|
||||
for (const [path, op] of diffEntries) {
|
||||
if (!this.pendingOps[path]) {
|
||||
this.pendingOps[path] = op;
|
||||
count++;
|
||||
|
|
@ -877,6 +904,8 @@ var NeoGDSync = class extends import_obsidian4.Plugin {
|
|||
}
|
||||
if (count > 0) {
|
||||
console.log(`[NeoGDSync] Startup diff: ${count} offline changes detected`);
|
||||
} else {
|
||||
console.log("[NeoGDSync] Startup diff: 0 offline changes — snapshot is current");
|
||||
}
|
||||
}
|
||||
// ── Sync ───────────────────────────────────────────────────────
|
||||
|
|
@ -978,12 +1007,15 @@ var NeoGDSync = class extends import_obsidian4.Plugin {
|
|||
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 : [];
|
||||
// Inject snapshot into VaultSnapshot instance
|
||||
if (this.snapshot) this.snapshot.setRaw(saved == null ? void 0 : saved.snapshot);
|
||||
}
|
||||
async saveSettings() {
|
||||
await this.saveData({
|
||||
settings: this.settings,
|
||||
pendingOps: this.pendingOps,
|
||||
conflicts: this.conflicts
|
||||
conflicts: this.conflicts,
|
||||
snapshot: this.snapshot ? this.snapshot.getAll() : {}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "neogdsync",
|
||||
"name": "NeoGDSync",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"minAppVersion": "1.6.0",
|
||||
"description": "Lightweight Google Drive sync with path-based index, conflict detection, and Smart/Push/Pull modes.",
|
||||
"author": "LM",
|
||||
|
|
|
|||
216
src/main.ts
216
src/main.ts
|
|
@ -1,8 +1,8 @@
|
|||
import {
|
||||
Plugin, Notice, TFile, TFolder, TAbstractFile,
|
||||
Plugin, Notice, TFile, TAbstractFile,
|
||||
PluginSettingTab, App, Setting, Modal, normalizePath,
|
||||
} from 'obsidian';
|
||||
import { NeoSettings, DEFAULT_SETTINGS, PendingOps, ConflictRecord } from './types';
|
||||
import { NeoSettings, DEFAULT_SETTINGS, PendingOps, ConflictRecord, Snapshot } from './types';
|
||||
import { DriveApi } from './driveApi';
|
||||
import { PathIndex } from './pathIndex';
|
||||
import { VaultSnapshot } from './snapshot';
|
||||
|
|
@ -16,27 +16,30 @@ export default class NeoGDSync extends Plugin {
|
|||
|
||||
drive!: DriveApi;
|
||||
index!: PathIndex;
|
||||
private snapshot!: VaultSnapshot;
|
||||
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);
|
||||
// Create snapshot BEFORE loadSettings so setRaw() can be called inside it
|
||||
this.snapshot = new VaultSnapshot(this.app);
|
||||
await this.loadSettings();
|
||||
|
||||
// Load index + snapshot
|
||||
this.drive = new DriveApi(this.settings.refreshToken);
|
||||
this.index = new PathIndex(this.app, this.drive, this.settings.vaultRootId);
|
||||
await this.index.load();
|
||||
await this.snapshot.load();
|
||||
// snapshot already loaded via setRaw() in loadSettings()
|
||||
|
||||
// Startup diff — catch offline changes
|
||||
this.mergeOfflineDiff();
|
||||
|
||||
// Register vault event listeners
|
||||
this.registerEvents();
|
||||
// IMPORTANT: delay both offline diff AND event registration until layout is ready.
|
||||
// Before onLayoutReady, vault.getFiles() returns stale/incorrect stat values
|
||||
// (mtime/size), which causes computeDiff to falsely flag all files as changed.
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
console.log(`[NeoGDSync] onLayoutReady: vault has ${this.app.vault.getFiles().length} files`);
|
||||
this.mergeOfflineDiff();
|
||||
this.registerEvents();
|
||||
});
|
||||
|
||||
// Ribbon icon
|
||||
const ribbonIcon = this.addRibbonIcon('cloud', 'NeoGDSync', () => this.openSyncModal());
|
||||
|
|
@ -47,42 +50,29 @@ export default class NeoGDSync extends Plugin {
|
|||
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(),
|
||||
});
|
||||
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));
|
||||
|
||||
// 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 Notice('NeoGDSync loaded ✓');
|
||||
}
|
||||
|
||||
async onunload() {
|
||||
// snapshot.save() MUST come before saveSettings() so fresh stats are persisted
|
||||
await this.snapshot.save(p => this.exclude(p));
|
||||
await this.saveSettings();
|
||||
await this.index.save();
|
||||
await this.snapshot.save(p => this.exclude(p));
|
||||
}
|
||||
|
||||
// ── Vault events ───────────────────────────────────────────────
|
||||
|
|
@ -94,19 +84,21 @@ export default class NeoGDSync extends Plugin {
|
|||
this.registerEvent(this.app.vault.on('rename', (f, old) => this.handleRename(f, old)));
|
||||
}
|
||||
|
||||
private exclude(path: string): boolean {
|
||||
exclude(path: string): boolean {
|
||||
if (path.startsWith('.neogdsync')) return true;
|
||||
if (path.startsWith('.obsidian')) return true;
|
||||
if (path.startsWith('.smart-env')) return true;
|
||||
if (path.startsWith('.smtcmp')) return true;
|
||||
if (path.endsWith('.DS_Store')) 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;
|
||||
if (!(f instanceof TFile)) return; // skip folders
|
||||
const cur = this.pendingOps[f.path];
|
||||
if (cur === 'delete') {
|
||||
this.pendingOps[f.path] = f instanceof TFile ? 'modify' : 'create';
|
||||
this.pendingOps[f.path] = 'modify';
|
||||
} else if (!cur) {
|
||||
this.pendingOps[f.path] = 'create';
|
||||
}
|
||||
|
|
@ -116,6 +108,9 @@ export default class NeoGDSync extends Plugin {
|
|||
|
||||
private handleModify(f: TAbstractFile) {
|
||||
if (this.exclude(f.path) || !(f instanceof TFile)) return;
|
||||
// Skip if file matches snapshot (avoids false positives on startup events)
|
||||
const snap = this.snapshot.get(f.path);
|
||||
if (snap && Math.abs(f.stat.mtime - snap.mtime) <= 2000 && f.stat.size === snap.size) return;
|
||||
if (!this.pendingOps[f.path]) {
|
||||
this.pendingOps[f.path] = 'modify';
|
||||
}
|
||||
|
|
@ -137,7 +132,6 @@ export default class NeoGDSync extends Plugin {
|
|||
|
||||
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';
|
||||
|
|
@ -151,24 +145,40 @@ export default class NeoGDSync extends Plugin {
|
|||
}
|
||||
|
||||
private mergeOfflineDiff() {
|
||||
const existing = this.snapshot.getAll();
|
||||
if (Object.keys(existing).length === 0) {
|
||||
// No snapshot yet — save current vault as baseline, don't flood pendingOps
|
||||
console.log('[NeoGDSync] No snapshot found — saving current vault as baseline');
|
||||
this.snapshot.save(p => this.exclude(p)).catch(console.error);
|
||||
const snapData = this.snapshot.getAll();
|
||||
const snapCount = Object.keys(snapData).length;
|
||||
const vaultCount = this.app.vault.getFiles().length;
|
||||
console.log(`[NeoGDSync] mergeOfflineDiff: snapshot=${snapCount} entries, vault=${vaultCount} files`);
|
||||
|
||||
if (snapCount === 0) {
|
||||
console.log('[NeoGDSync] No snapshot — saving current vault as baseline, skipping offline diff');
|
||||
this.snapshot.save(p => this.exclude(p)).then(() => this.saveSettings()).catch(console.error);
|
||||
return;
|
||||
}
|
||||
|
||||
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++;
|
||||
const diffEntries = Object.entries(diff);
|
||||
console.log(`[NeoGDSync] computeDiff returned ${diffEntries.length} ops`);
|
||||
|
||||
// Log sample so root cause of any surprises is visible in DevTools
|
||||
for (const [path, op] of diffEntries.slice(0, 5)) {
|
||||
const f = this.app.vault.getAbstractFileByPath(path);
|
||||
const snap = snapData[path];
|
||||
if (f instanceof TFile && snap) {
|
||||
console.log(`[NeoGDSync] ${op}: ${path} | mtime diff=${f.stat.mtime - snap.mtime}ms | size: ${snap.size}→${f.stat.size}`);
|
||||
} else {
|
||||
console.log(`[NeoGDSync] ${op}: ${path} | f=${!!f} snap=${!!snap}`);
|
||||
}
|
||||
}
|
||||
if (count > 0) {
|
||||
console.log(`[NeoGDSync] Startup diff: ${count} offline changes detected`);
|
||||
|
||||
let count = 0;
|
||||
for (const [path, op] of diffEntries) {
|
||||
if (!this.pendingOps[path]) { this.pendingOps[path] = op; count++; }
|
||||
}
|
||||
console.log(count > 0
|
||||
? `[NeoGDSync] Startup diff: ${count} offline changes detected`
|
||||
: '[NeoGDSync] Startup diff: 0 changes — snapshot is current');
|
||||
this.updateStatus();
|
||||
}
|
||||
|
||||
// ── Sync ───────────────────────────────────────────────────────
|
||||
|
|
@ -204,12 +214,8 @@ export default class NeoGDSync extends Plugin {
|
|||
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);
|
||||
}
|
||||
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);
|
||||
|
|
@ -237,13 +243,8 @@ export default class NeoGDSync extends Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
showConflicts() {
|
||||
new ConflictModal(this.app, this.conflicts).open();
|
||||
}
|
||||
|
||||
openSyncModal() {
|
||||
new SyncModal(this.app, this).open();
|
||||
}
|
||||
showConflicts() { new ConflictModal(this.app, this.conflicts).open(); }
|
||||
openSyncModal() { new SyncModal(this.app, this).open(); }
|
||||
|
||||
// ── Status bar ─────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -267,16 +268,19 @@ export default class NeoGDSync extends Plugin {
|
|||
|
||||
async loadSettings() {
|
||||
const saved = await this.loadData();
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, saved?.settings ?? {});
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, saved?.settings ?? {});
|
||||
this.pendingOps = saved?.pendingOps ?? {};
|
||||
this.conflicts = saved?.conflicts ?? [];
|
||||
// Inject snapshot (purges .obsidian entries via setRaw)
|
||||
if (this.snapshot) this.snapshot.setRaw(saved?.snapshot);
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData({
|
||||
settings: this.settings,
|
||||
settings: this.settings,
|
||||
pendingOps: this.pendingOps,
|
||||
conflicts: this.conflicts,
|
||||
conflicts: this.conflicts,
|
||||
snapshot: this.snapshot ? this.snapshot.getAll() : {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -303,13 +307,10 @@ class SyncModal extends Modal {
|
|||
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(); };
|
||||
|
|
@ -327,17 +328,12 @@ class ConflictModal extends Modal {
|
|||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.createEl('h2', { text: `Conflicts (${this.conflicts.length})` });
|
||||
if (!this.conflicts.length) {
|
||||
contentEl.createEl('p', { text: 'No conflicts.' });
|
||||
return;
|
||||
}
|
||||
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('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}` });
|
||||
}
|
||||
|
|
@ -359,9 +355,7 @@ class NeoSettingsTab extends PluginSettingTab {
|
|||
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)
|
||||
.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());
|
||||
|
|
@ -375,47 +369,31 @@ class NeoSettingsTab extends PluginSettingTab {
|
|||
.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();
|
||||
});
|
||||
t.setPlaceholder('1xGNFQGB…').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 Setting(containerEl)
|
||||
.setName('Sync Mode')
|
||||
.setDesc('Default sync mode when using ribbon icon')
|
||||
.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();
|
||||
}));
|
||||
.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();
|
||||
}));
|
||||
.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`)
|
||||
.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();
|
||||
|
|
@ -424,16 +402,12 @@ class NeoSettingsTab extends PluginSettingTab {
|
|||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Rebuild Drive Index')
|
||||
.setDesc('Crawl Drive vault from root and rebuild local index.db')
|
||||
.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'} | ` +
|
||||
containerEl.createEl('p').setText(
|
||||
`Last sync: ${this.plugin.settings.lastSyncedAt ? new Date(this.plugin.settings.lastSyncedAt).toLocaleString() : 'never'} | ` +
|
||||
`Conflicts: ${this.plugin.conflicts.length}`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,39 @@
|
|||
/**
|
||||
* 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.
|
||||
* VaultSnapshot — records vault file stats after each sync.
|
||||
* Stored in data.json (via plugin.saveSettings/loadSettings), not in a separate file.
|
||||
* On startup, diff current vault against snapshot to detect offline changes.
|
||||
*
|
||||
* Key design decisions:
|
||||
* - load() is a no-op; data is injected via setRaw() from loadSettings()
|
||||
* - save() updates in-memory state; caller must saveSettings() to persist
|
||||
* - setRaw() purges .obsidian entries (they're not in vault.getFiles())
|
||||
*/
|
||||
|
||||
import { App, TFile, TFolder, normalizePath } from 'obsidian';
|
||||
import { App, TFile } from 'obsidian';
|
||||
import { Snapshot, PendingOps } from './types';
|
||||
|
||||
const SNAPSHOT_PATH = '.neogdsync/snapshot.json';
|
||||
|
||||
export class VaultSnapshot {
|
||||
private snapshot: Snapshot = {};
|
||||
|
||||
constructor(private app: App) {}
|
||||
|
||||
async load(): Promise<void> {
|
||||
try {
|
||||
const raw = await this.app.vault.adapter.read(normalizePath(SNAPSHOT_PATH));
|
||||
this.snapshot = JSON.parse(raw);
|
||||
} catch {
|
||||
this.snapshot = {};
|
||||
/** Injected by plugin.loadSettings() — purges .obsidian entries defensively. */
|
||||
setRaw(data: Snapshot | undefined): void {
|
||||
const raw = data || {};
|
||||
// Purge any .obsidian entries that may have been saved in earlier versions
|
||||
for (const key of Object.keys(raw)) {
|
||||
if (key.startsWith('.obsidian')) delete (raw as Record<string, unknown>)[key];
|
||||
}
|
||||
this.snapshot = raw;
|
||||
}
|
||||
|
||||
/** No-op: data is injected via setRaw() from loadSettings(). */
|
||||
async load(): Promise<void> {}
|
||||
|
||||
/**
|
||||
* Rebuild snapshot from current vault state.
|
||||
* Updates in-memory snapshot only; caller must call saveSettings() to persist.
|
||||
*/
|
||||
async save(exclude: (path: string) => boolean): Promise<void> {
|
||||
const fresh: Snapshot = {};
|
||||
const files = this.app.vault.getFiles();
|
||||
|
|
@ -32,20 +43,12 @@ export class VaultSnapshot {
|
|||
}
|
||||
}
|
||||
this.snapshot = fresh;
|
||||
// Ensure .neogdsync/ directory exists before writing
|
||||
const dir = normalizePath('.neogdsync');
|
||||
if (!(await this.app.vault.adapter.exists(dir))) {
|
||||
await this.app.vault.adapter.mkdir(dir);
|
||||
}
|
||||
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.
|
||||
* Must be called after onLayoutReady so vault.getFiles() returns accurate stats.
|
||||
*/
|
||||
computeDiff(exclude: (path: string) => boolean): PendingOps {
|
||||
const ops: PendingOps = {};
|
||||
|
|
@ -58,12 +61,12 @@ export class VaultSnapshot {
|
|||
const snap = this.snapshot[f.path];
|
||||
if (!snap) {
|
||||
ops[f.path] = 'create';
|
||||
} else if (f.stat.mtime > snap.mtime || f.stat.size !== snap.size) {
|
||||
} else if ((f.stat.mtime - snap.mtime > 2000) || f.stat.size !== snap.size) {
|
||||
ops[f.path] = 'modify';
|
||||
}
|
||||
}
|
||||
|
||||
// Deleted files
|
||||
// Deleted files — in snapshot but not in current vault
|
||||
for (const p of Object.keys(this.snapshot)) {
|
||||
if (!currentPaths.has(p)) {
|
||||
ops[p] = 'delete';
|
||||
|
|
|
|||
Loading…
Reference in a new issue