diff --git a/src/commands.ts b/src/commands.ts index 604bc72..a6f647f 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -6,7 +6,7 @@ * Plugin focuses on: status panel, task creation, vault stats. */ -import { Notice, TFile, TFolder } from "obsidian"; +import { Notice } from "obsidian"; import type ClawVaultPlugin from "./main"; import { COMMAND_IDS, STATUS_VIEW_TYPE } from "./constants"; import { @@ -20,6 +20,52 @@ import { * Register all ClawVault commands */ export function registerCommands(plugin: ClawVaultPlugin): void { + // Sync now command + plugin.addCommand({ + id: COMMAND_IDS.SYNC_NOW, + name: "ClawVault: Sync Now", + hotkeys: [{ modifiers: ["Ctrl", "Shift"], key: "s" }], + callback: () => { + void plugin.syncNow("full"); + }, + }); + + // Pull-only sync command + plugin.addCommand({ + id: COMMAND_IDS.SYNC_PULL, + name: "ClawVault: Sync Pull", + callback: () => { + void plugin.syncNow("pull"); + }, + }); + + // Push-only sync command + plugin.addCommand({ + id: COMMAND_IDS.SYNC_PUSH, + name: "ClawVault: Sync Push", + callback: () => { + void plugin.syncNow("push"); + }, + }); + + // Focus sync status in the sidebar + plugin.addCommand({ + id: COMMAND_IDS.SHOW_SYNC_STATUS, + name: "ClawVault: Show Sync Status", + callback: () => { + void plugin.focusSyncStatusSection(); + }, + }); + + // Open sync settings + plugin.addCommand({ + id: COMMAND_IDS.CONFIGURE_SYNC, + name: "ClawVault: Configure Sync", + callback: () => { + plugin.openPluginSettings(); + }, + }); + // Quick Capture command plugin.addCommand({ id: COMMAND_IDS.QUICK_CAPTURE, diff --git a/src/constants.ts b/src/constants.ts index b489895..c98b23f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -73,8 +73,15 @@ export const COMMAND_IDS = { GENERATE_CANVAS_FROM_TEMPLATE: "clawvault-generate-canvas-from-template", REFRESH_STATS: "clawvault-refresh-stats", SHOW_OPEN_LOOPS: "clawvault-show-open-loops", + SYNC_NOW: "clawvault-sync-now", + SYNC_PULL: "clawvault-sync-pull", + SYNC_PUSH: "clawvault-sync-push", + SHOW_SYNC_STATUS: "clawvault-sync-status", + CONFIGURE_SYNC: "clawvault-sync-configure", } as const; +export const MIN_SYNC_INTERVAL_MINUTES = 5; + export const CANVAS_TEMPLATE_IDS = { PROJECT_BOARD: "project-board", BRAIN_OVERVIEW: "brain-overview", diff --git a/src/main.ts b/src/main.ts index 568cc5c..a1438f4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,7 +3,7 @@ * Visual memory management for ClawVault vaults */ -import { Plugin, WorkspaceLeaf } from "obsidian"; +import { Notice, Plugin, WorkspaceLeaf } from "obsidian"; import { ClawVaultSettings, ClawVaultSettingTab, DEFAULT_SETTINGS } from "./settings"; import { VaultReader } from "./vault-reader"; import { ClawVaultStatusView } from "./status-view"; @@ -13,9 +13,18 @@ import { GraphEnhancer } from "./graph-enhancer"; import { registerCommands } from "./commands"; import { DEFAULT_CATEGORY_COLORS, + MIN_SYNC_INTERVAL_MINUTES, STATUS_VIEW_TYPE, // TASK_BOARD_VIEW_TYPE removed โ€” using Kanban plugin } from "./constants"; +import { SyncClient } from "./sync/sync-client"; +import { SyncEngine } from "./sync/sync-engine"; +import { + DEFAULT_SYNC_SETTINGS, + type SyncMode, + type SyncResult, + type SyncRuntimeState, +} from "./sync/sync-types"; export default class ClawVaultPlugin extends Plugin { settings: ClawVaultSettings = DEFAULT_SETTINGS; @@ -23,8 +32,20 @@ export default class ClawVaultPlugin extends Plugin { private statusBarItem: HTMLElement | null = null; private refreshIntervalId: number | null = null; + private syncIntervalId: number | null = null; private fileDecorations: FileDecorations | null = null; private graphEnhancer: GraphEnhancer | null = null; + private settingTab: ClawVaultSettingTab | null = null; + private syncClient: SyncClient | null = null; + private syncEngine: SyncEngine | null = null; + private syncState: SyncRuntimeState = { + status: "disconnected", + serverUrl: "", + message: "Sync server not configured", + lastSyncTimestamp: 0, + lastSyncStats: null, + progress: null, + }; async onload(): Promise { await this.loadSettings(); @@ -43,6 +64,9 @@ export default class ClawVaultPlugin extends Plugin { this.addRibbonIcon("database", "ClawVault status", () => { void this.activateStatusView(); }); + this.addRibbonIcon("refresh-cw", "ClawVault sync now", () => { + void this.syncNow("full"); + }); // Add status bar item this.statusBarItem = this.addStatusBarItem(); @@ -56,7 +80,8 @@ export default class ClawVaultPlugin extends Plugin { registerCommands(this); // Add settings tab - this.addSettingTab(new ClawVaultSettingTab(this.app, this)); + this.settingTab = new ClawVaultSettingTab(this.app, this); + this.addSettingTab(this.settingTab); // Initialize file decorations this.fileDecorations = new FileDecorations(this); @@ -66,6 +91,9 @@ export default class ClawVaultPlugin extends Plugin { this.graphEnhancer = new GraphEnhancer(this); this.graphEnhancer.initialize(); + // Initialize sync modules + this.reconfigureSync(); + // Start refresh interval this.startRefreshInterval(); @@ -78,6 +106,13 @@ export default class ClawVaultPlugin extends Plugin { void this.autoSetupGraphColors(); }); } + + // Optional sync on open + this.app.workspace.onLayoutReady(() => { + if (this.settings.sync.syncOnOpen && this.settings.sync.serverUrl.trim().length > 0) { + void this.syncNow("full", true); + } + }); } /** @@ -98,12 +133,22 @@ export default class ClawVaultPlugin extends Plugin { } onunload(): void { + if (this.settings.sync.syncOnClose) { + void this.syncNow("full", true); + } + // Clean up refresh interval if (this.refreshIntervalId !== null) { window.clearInterval(this.refreshIntervalId); this.refreshIntervalId = null; } + // Clean up sync interval + if (this.syncIntervalId !== null) { + window.clearInterval(this.syncIntervalId); + this.syncIntervalId = null; + } + // Clean up file decorations if (this.fileDecorations) { this.fileDecorations.cleanup(); @@ -115,6 +160,7 @@ export default class ClawVaultPlugin extends Plugin { this.graphEnhancer.cleanup(); this.graphEnhancer = null; } + this.settingTab = null; // Note: Don't detach leaves in onunload per Obsidian guidelines // The view will be properly cleaned up by Obsidian @@ -122,7 +168,10 @@ export default class ClawVaultPlugin extends Plugin { async loadSettings(): Promise { const data = await this.loadData() as Partial | null; - this.settings = Object.assign({}, DEFAULT_SETTINGS, data); + const syncSettings = Object.assign({}, DEFAULT_SYNC_SETTINGS, data?.sync ?? {}); + this.settings = Object.assign({}, DEFAULT_SETTINGS, data, { + sync: syncSettings, + }); // Merge category colors with defaults this.settings.categoryColors = Object.assign( @@ -130,6 +179,12 @@ export default class ClawVaultPlugin extends Plugin { DEFAULT_CATEGORY_COLORS, this.settings.categoryColors ); + + // Guard interval bounds and stale sync values + this.settings.sync.autoSyncInterval = Math.max( + MIN_SYNC_INTERVAL_MINUTES, + this.settings.sync.autoSyncInterval + ); } async saveSettings(): Promise { @@ -184,8 +239,9 @@ export default class ClawVaultPlugin extends Plugin { try { const stats = await this.vaultReader.getVaultStats(); const activeTaskCount = stats.tasks.active + stats.tasks.open; + const syncSuffix = this.formatSyncStatusBarSuffix(); this.statusBarItem.setText( - `๐Ÿ˜ ${stats.nodeCount.toLocaleString()} nodes ยท ${activeTaskCount} tasks` + `๐Ÿ˜ ${stats.nodeCount.toLocaleString()} nodes ยท ${activeTaskCount} tasks${syncSuffix}` ); } catch { this.statusBarItem.setText("๐Ÿ˜ ClawVault"); @@ -215,6 +271,213 @@ export default class ClawVaultPlugin extends Plugin { this.startRefreshInterval(); } + reconfigureSync(): void { + const serverUrl = this.settings.sync.serverUrl.trim(); + const authEnabled = + this.settings.sync.authUsername.trim().length > 0 || + this.settings.sync.authPassword.length > 0; + + if (!serverUrl) { + this.syncClient = null; + this.syncEngine = null; + this.stopSyncInterval(); + this.setSyncState({ + status: "disconnected", + serverUrl: "", + message: "Sync server not configured", + progress: null, + }); + return; + } + + const clientConfig = { + serverUrl, + auth: authEnabled + ? { + username: this.settings.sync.authUsername, + password: this.settings.sync.authPassword, + } + : undefined, + timeout: 30000, + }; + + if (!this.syncClient) { + this.syncClient = new SyncClient(clientConfig); + } else { + this.syncClient.updateConfig(clientConfig); + } + + if (!this.syncEngine) { + this.syncEngine = new SyncEngine(this.app, this.syncClient, this.settings.sync); + } else { + this.syncEngine.updateSettings(this.settings.sync); + } + + this.setSyncState({ + status: "idle", + serverUrl, + message: "Ready", + progress: null, + }); + this.configureSyncInterval(); + } + + getSyncState(): SyncRuntimeState { + return { + ...this.syncState, + serverUrl: this.settings.sync.serverUrl, + lastSyncTimestamp: this.settings.sync.lastSyncTimestamp, + lastSyncStats: this.settings.sync.lastSyncStats, + }; + } + + async syncNow(mode: SyncMode = "full", silent = false): Promise { + if (!this.syncEngine || !this.syncClient) { + if (!silent) { + new Notice("ClawVault sync: configure a server URL first."); + } + this.setSyncState({ + status: "disconnected", + message: "Sync server not configured", + progress: null, + }); + return null; + } + + if (this.syncState.status === "syncing") { + if (!silent) { + new Notice("ClawVault sync is already running."); + } + return null; + } + + this.setSyncState({ + status: "syncing", + message: "Sync in progress", + progress: { + stage: "planning", + current: 0, + total: 1, + message: "Planning sync...", + }, + }); + + try { + const result = await this.syncEngine.sync(mode, (progress) => { + this.setSyncState({ + status: "syncing", + message: progress.message ?? "Sync in progress", + progress, + }); + }); + + this.settings.sync.lastSyncTimestamp = result.endedAt; + this.settings.sync.lastSyncStats = { + pulled: result.pulled, + pushed: result.pushed, + conflicts: result.conflicts, + }; + await this.saveSettings(); + + const errorMessage = + result.errors.length > 0 + ? `Completed with ${result.errors.length} error${result.errors.length === 1 ? "" : "s"}` + : "Synced successfully"; + this.setSyncState({ + status: "idle", + message: errorMessage, + progress: null, + }); + + if (!silent) { + new Notice( + `ClawVault sync: โ†“ ${result.pulled}, โ†‘ ${result.pushed}, โšก ${result.conflicts}` + ); + } + + if (result.pulled > 0 || result.pushed > 0) { + await this.refreshAll(); + } else { + await this.refreshStatusViews(); + } + + return result; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown sync error"; + this.setSyncState({ + status: "error", + message, + progress: null, + }); + if (!silent) { + new Notice(`ClawVault sync failed: ${message}`); + } + return null; + } + } + + async testSyncConnection(): Promise { + if (!this.settings.sync.serverUrl.trim()) { + new Notice("ClawVault sync: set a server URL first."); + return false; + } + + this.reconfigureSync(); + if (!this.syncClient) { + new Notice("ClawVault sync: failed to configure sync client."); + return false; + } + + try { + const health = await this.syncClient.healthCheck(); + this.setSyncState({ + status: "idle", + message: `Connected (${health.status})`, + progress: null, + }); + new Notice(`ClawVault sync connected to vault: ${health.vault}`); + return true; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown connection error"; + this.setSyncState({ + status: "error", + message, + progress: null, + }); + new Notice(`ClawVault sync connection failed: ${message}`); + return false; + } + } + + openPluginSettings(): void { + const settingApi = ( + this.app as typeof this.app & { + setting?: { + open: () => void; + openTabById: (id: string) => void; + }; + } + ).setting; + settingApi?.open(); + settingApi?.openTabById(this.manifest.id); + window.setTimeout(() => { + this.settingTab?.focusSyncSection(); + }, 50); + } + + async focusSyncStatusSection(): Promise { + await this.activateStatusView(); + const leaves = this.app.workspace.getLeavesOfType(STATUS_VIEW_TYPE); + for (const leaf of leaves) { + const view = leaf.view; + if (view instanceof ClawVaultStatusView) { + view.focusSyncSection(); + } + } + } + /** * Refresh all plugin data */ @@ -226,13 +489,7 @@ export default class ClawVaultPlugin extends Plugin { await this.updateStatusBar(); // Refresh status view if open - const leaves = this.app.workspace.getLeavesOfType(STATUS_VIEW_TYPE); - for (const leaf of leaves) { - const view = leaf.view; - if (view instanceof ClawVaultStatusView) { - await view.refresh(); - } - } + await this.refreshStatusViews(); // Task board removed โ€” Kanban plugin handles task visualization @@ -253,4 +510,89 @@ export default class ClawVaultPlugin extends Plugin { this.graphEnhancer?.applyCategoryVariables(); this.graphEnhancer?.scheduleEnhance(40); } + + private configureSyncInterval(): void { + this.stopSyncInterval(); + + if ( + !this.settings.sync.autoSyncEnabled || + !this.settings.sync.serverUrl.trim() + ) { + return; + } + + const intervalMinutes = Math.max( + MIN_SYNC_INTERVAL_MINUTES, + this.settings.sync.autoSyncInterval + ); + const intervalMs = intervalMinutes * 60 * 1000; + this.syncIntervalId = window.setInterval(() => { + void this.syncNow("full", true); + }, intervalMs); + this.registerInterval(this.syncIntervalId); + } + + private stopSyncInterval(): void { + if (this.syncIntervalId !== null) { + window.clearInterval(this.syncIntervalId); + this.syncIntervalId = null; + } + } + + private setSyncState(next: Partial): void { + this.syncState = { + ...this.syncState, + ...next, + serverUrl: this.settings.sync.serverUrl, + lastSyncTimestamp: this.settings.sync.lastSyncTimestamp, + lastSyncStats: this.settings.sync.lastSyncStats, + }; + void this.updateStatusBar(); + this.refreshSyncStateViews(); + } + + private refreshSyncStateViews(): void { + const leaves = this.app.workspace.getLeavesOfType(STATUS_VIEW_TYPE); + for (const leaf of leaves) { + const view = leaf.view; + if (view instanceof ClawVaultStatusView) { + view.refreshSyncState(); + } + } + } + + private async refreshStatusViews(): Promise { + const leaves = this.app.workspace.getLeavesOfType(STATUS_VIEW_TYPE); + for (const leaf of leaves) { + const view = leaf.view; + if (view instanceof ClawVaultStatusView) { + await view.refresh(); + } + } + } + + private formatSyncStatusBarSuffix(): string { + if (this.syncState.status === "syncing") { + return " ยท โ†• syncing..."; + } + + if (!this.settings.sync.lastSyncTimestamp) { + return ""; + } + + const diffMs = Date.now() - this.settings.sync.lastSyncTimestamp; + const diffMinutes = Math.floor(diffMs / 60000); + if (diffMinutes < 1) { + return " ยท โ†• synced just now"; + } + if (diffMinutes < 60) { + return ` ยท โ†• synced ${diffMinutes}m ago`; + } + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) { + return ` ยท โ†• synced ${diffHours}h ago`; + } + const diffDays = Math.floor(diffHours / 24); + return ` ยท โ†• synced ${diffDays}d ago`; + } } diff --git a/src/settings.ts b/src/settings.ts index 1a291b4..9f7d922 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -5,7 +5,16 @@ import { App, PluginSettingTab, Setting } from "obsidian"; import type ClawVaultPlugin from "./main"; -import { DEFAULT_CATEGORY_COLORS, DEFAULT_REFRESH_INTERVAL } from "./constants"; +import { + DEFAULT_CATEGORY_COLORS, + DEFAULT_REFRESH_INTERVAL, + MIN_SYNC_INTERVAL_MINUTES, +} from "./constants"; +import { + DEFAULT_SYNC_SETTINGS, + type ConflictStrategy, + type SyncSettings, +} from "./sync/sync-types"; /** * Plugin settings interface @@ -28,6 +37,9 @@ export interface ClawVaultSettings { // Whether graph colors have been auto-configured graphColorsConfigured: boolean; + + // Built-in sync settings + sync: SyncSettings; } /** @@ -40,6 +52,7 @@ export const DEFAULT_SETTINGS: ClawVaultSettings = { showStatusBar: true, showFileDecorations: true, graphColorsConfigured: false, + sync: { ...DEFAULT_SYNC_SETTINGS }, }; /** @@ -47,6 +60,8 @@ export const DEFAULT_SETTINGS: ClawVaultSettings = { */ export class ClawVaultSettingTab extends PluginSettingTab { plugin: ClawVaultPlugin; + private showAuthFields = false; + private syncSectionHeadingEl: HTMLElement | null = null; constructor(app: App, plugin: ClawVaultPlugin) { super(app, plugin); @@ -56,6 +71,14 @@ export class ClawVaultSettingTab extends PluginSettingTab { display(): void { const { containerEl } = this; containerEl.empty(); + containerEl.addClass("clawvault-settings"); + this.syncSectionHeadingEl = null; + this.showAuthFields = + this.showAuthFields || + Boolean( + this.plugin.settings.sync.authUsername || + this.plugin.settings.sync.authPassword + ); // Header new Setting(containerEl).setName("ClawVault settings").setHeading(); @@ -174,6 +197,191 @@ export class ClawVaultSettingTab extends PluginSettingTab { }) ); + // Sync section + const syncHeading = new Setting(containerEl).setName("Sync").setHeading(); + this.syncSectionHeadingEl = syncHeading.settingEl; + + new Setting(containerEl) + .setName("Server URL") + .setDesc("Base URL for clawvault serve (for example, http://100.64.31.68:8384)") + .addText((text) => + text + .setPlaceholder("http://100.64.31.68:8384") + .setValue(this.plugin.settings.sync.serverUrl) + .onChange(async (value) => { + this.plugin.settings.sync.serverUrl = value.trim(); + await this.saveSyncSettings(); + }) + ) + .addButton((button) => + button + .setButtonText("Test connection") + .onClick(async () => { + await this.plugin.testSyncConnection(); + }) + ); + + new Setting(containerEl) + .setName("Use Basic auth") + .setDesc("Enable username/password authentication for the sync server") + .addToggle((toggle) => + toggle + .setValue(this.showAuthFields) + .onChange((value) => { + this.showAuthFields = value; + this.display(); + }) + ); + + if (this.showAuthFields) { + new Setting(containerEl) + .setName("Auth username") + .setDesc("Optional basic auth username") + .addText((text) => + text + .setPlaceholder("username") + .setValue(this.plugin.settings.sync.authUsername) + .onChange(async (value) => { + this.plugin.settings.sync.authUsername = value.trim(); + await this.saveSyncSettings(); + }) + ); + + new Setting(containerEl) + .setName("Auth password") + .setDesc("Optional basic auth password") + .addText((text) => { + text.inputEl.type = "password"; + text + .setPlaceholder("password") + .setValue(this.plugin.settings.sync.authPassword) + .onChange(async (value) => { + this.plugin.settings.sync.authPassword = value; + await this.saveSyncSettings(); + }); + }); + } + + new Setting(containerEl) + .setName("Auto-sync enabled") + .setDesc("Run automatic sync on startup and at a periodic interval") + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.sync.autoSyncEnabled) + .onChange(async (value) => { + this.plugin.settings.sync.autoSyncEnabled = value; + await this.saveSyncSettings(); + }) + ); + + new Setting(containerEl) + .setName("Auto-sync interval") + .setDesc(`Minutes between background sync runs (minimum ${MIN_SYNC_INTERVAL_MINUTES} min)`) + .addSlider((slider) => + slider + .setLimits(MIN_SYNC_INTERVAL_MINUTES, 120, 5) + .setValue(this.plugin.settings.sync.autoSyncInterval) + .setDynamicTooltip() + .onChange(async (value) => { + this.plugin.settings.sync.autoSyncInterval = Math.max( + MIN_SYNC_INTERVAL_MINUTES, + value + ); + await this.saveSyncSettings(); + }) + ); + + new Setting(containerEl) + .setName("Sync on app open") + .setDesc("Run a sync when Obsidian finishes loading") + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.sync.syncOnOpen) + .onChange(async (value) => { + this.plugin.settings.sync.syncOnOpen = value; + await this.saveSyncSettings(); + }) + ); + + new Setting(containerEl) + .setName("Sync on app close") + .setDesc("Attempt sync during plugin unload (can be unreliable on mobile)") + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.sync.syncOnClose) + .onChange(async (value) => { + this.plugin.settings.sync.syncOnClose = value; + await this.saveSyncSettings(); + }) + ); + + new Setting(containerEl) + .setName("Exclude patterns") + .setDesc("Comma-separated glob patterns to exclude from sync") + .addTextArea((text) => + text + .setPlaceholder(".trash/**, attachments/*.tmp") + .setValue(this.plugin.settings.sync.excludePatterns.join(", ")) + .onChange(async (value) => { + this.plugin.settings.sync.excludePatterns = this.parseList(value); + await this.saveSyncSettings(); + }) + ); + + new Setting(containerEl) + .setName("Conflict strategy") + .setDesc("How to resolve files changed on both local and remote") + .addDropdown((dropdown) => { + const labels: Record = { + "newest-wins": "Newest wins", + "remote-wins": "Remote wins", + "local-wins": "Local wins", + "keep-both": "Keep both copies", + ask: "Ask every time", + }; + for (const [value, label] of Object.entries(labels)) { + dropdown.addOption(value, label); + } + dropdown + .setValue(this.plugin.settings.sync.conflictStrategy) + .onChange(async (value) => { + this.plugin.settings.sync.conflictStrategy = value as ConflictStrategy; + await this.saveSyncSettings(); + }); + }); + + const categoriesHeader = containerEl.createEl("div", { + text: "Sync categories", + cls: "setting-item-name", + }); + categoriesHeader.style.marginTop = "10px"; + containerEl.createEl("div", { + text: "Choose which top-level categories to sync. If all are enabled, the setting is saved as 'all categories'.", + cls: "setting-item-description", + }); + + const categoryContainer = containerEl.createDiv({ + cls: "clawvault-sync-category-container", + }); + categoryContainer.createEl("div", { + text: "Loading categories...", + cls: "setting-item-description", + }); + void this.renderSyncCategorySettings(categoryContainer); + + new Setting(containerEl) + .setName("Sync now") + .setDesc(this.formatLastSyncSummary()) + .addButton((button) => + button + .setButtonText("Sync now") + .setCta() + .onClick(async () => { + await this.plugin.syncNow("full"); + this.display(); + }) + ); + // About section new Setting(containerEl).setName("About").setHeading(); @@ -191,4 +399,110 @@ export class ClawVaultSettingTab extends PluginSettingTab { private formatCategoryName(category: string): string { return category.charAt(0).toUpperCase() + category.slice(1); } + + private async saveSyncSettings(): Promise { + await this.plugin.saveSettings(); + this.plugin.reconfigureSync(); + } + + private parseList(value: string): string[] { + return value + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0); + } + + private async renderSyncCategorySettings(containerEl: HTMLElement): Promise { + containerEl.empty(); + const config = await this.plugin.vaultReader.readConfig(); + const categorySet = new Set(); + for (const category of config?.categories ?? []) { + const trimmed = category.trim(); + if (trimmed.length > 0) { + categorySet.add(trimmed); + } + } + for (const category of Object.keys(DEFAULT_CATEGORY_COLORS)) { + categorySet.add(category); + } + + const categories = Array.from(categorySet).sort((a, b) => a.localeCompare(b)); + if (categories.length === 0) { + containerEl.createEl("div", { + text: "No categories discovered yet.", + cls: "setting-item-description", + }); + return; + } + + for (const category of categories) { + const allSelected = this.plugin.settings.sync.syncCategories.length === 0; + const isSelected = + allSelected || this.plugin.settings.sync.syncCategories.includes(category); + + new Setting(containerEl) + .setName(this.formatCategoryName(category)) + .addToggle((toggle) => + toggle.setValue(isSelected).onChange(async (enabled) => { + await this.toggleSyncCategory(categories, category, enabled); + }) + ); + } + } + + private async toggleSyncCategory( + allCategories: string[], + category: string, + enabled: boolean + ): Promise { + const selected = new Set(this.plugin.settings.sync.syncCategories); + const currentlyAll = selected.size === 0; + const nextSelection = currentlyAll ? new Set(allCategories) : new Set(selected); + + if (enabled) { + nextSelection.add(category); + } else { + nextSelection.delete(category); + } + + if ( + nextSelection.size === allCategories.length || + nextSelection.size === 0 + ) { + this.plugin.settings.sync.syncCategories = []; + } else { + this.plugin.settings.sync.syncCategories = Array.from(nextSelection).sort( + (a, b) => a.localeCompare(b) + ); + } + + await this.saveSyncSettings(); + this.display(); + } + + private formatLastSyncSummary(): string { + const sync = this.plugin.settings.sync; + if (!sync.lastSyncTimestamp) { + return "Last sync: never"; + } + + const when = new Date(sync.lastSyncTimestamp).toLocaleString(); + if (!sync.lastSyncStats) { + return `Last sync: ${when}`; + } + + return `Last sync: ${when} (โ†“ ${sync.lastSyncStats.pulled} ยท โ†‘ ${sync.lastSyncStats.pushed} ยท โšก ${sync.lastSyncStats.conflicts})`; + } + + focusSyncSection(): void { + if (!this.syncSectionHeadingEl) return; + this.syncSectionHeadingEl.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + this.syncSectionHeadingEl.classList.add("clawvault-sync-highlight"); + window.setTimeout(() => { + this.syncSectionHeadingEl?.classList.remove("clawvault-sync-highlight"); + }, 1200); + } } diff --git a/src/status-view.ts b/src/status-view.ts index 3074e51..8cb65f4 100644 --- a/src/status-view.ts +++ b/src/status-view.ts @@ -7,6 +7,7 @@ import { ItemView, TFile, WorkspaceLeaf } from "obsidian"; import type ClawVaultPlugin from "./main"; import { COMMAND_IDS, STATUS_VIEW_TYPE } from "./constants"; import type { ObservationSession, ParsedTask, VaultStats } from "./vault-reader"; +import { renderSyncStatusSection } from "./sync/sync-status-view"; interface StatusViewData { stats: VaultStats; @@ -23,6 +24,8 @@ interface StatusViewData { export class ClawVaultStatusView extends ItemView { plugin: ClawVaultPlugin; private statusContentEl: HTMLElement | null = null; + private syncSectionEl: HTMLElement | null = null; + private syncSectionHostEl: HTMLElement | null = null; constructor(leaf: WorkspaceLeaf, plugin: ClawVaultPlugin) { super(leaf); @@ -54,6 +57,8 @@ export class ClawVaultStatusView extends ItemView { async onClose(): Promise { this.statusContentEl = null; + this.syncSectionEl = null; + this.syncSectionHostEl = null; } /** @@ -109,6 +114,9 @@ export class ClawVaultStatusView extends ItemView { cls: "clawvault-status-counts", }); + this.syncSectionHostEl = this.statusContentEl.createDiv(); + this.renderSyncSection(); + // Memory Graph section if (stats.nodeCount > 0 || Object.keys(graphTypes).length > 0) { const graphSection = this.statusContentEl.createDiv({ cls: "clawvault-status-section" }); @@ -437,4 +445,40 @@ export class ClawVaultStatusView extends ItemView { await commandManager.executeCommandById(commandId); } } + + refreshSyncState(): void { + this.renderSyncSection(); + } + + focusSyncSection(): void { + if (!this.syncSectionEl) return; + this.syncSectionEl.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + this.syncSectionEl.classList.add("clawvault-sync-highlight"); + window.setTimeout(() => { + this.syncSectionEl?.classList.remove("clawvault-sync-highlight"); + }, 1200); + } + + private renderSyncSection(): void { + if (!this.syncSectionHostEl) return; + this.syncSectionHostEl.empty(); + this.syncSectionEl = renderSyncStatusSection( + this.syncSectionHostEl, + this.plugin.getSyncState(), + { + onSyncNow: () => { + void this.plugin.syncNow("full"); + }, + onConfigure: () => { + this.plugin.openPluginSettings(); + }, + onRetry: () => { + void this.plugin.testSyncConnection(); + }, + } + ); + } } diff --git a/src/sync/sync-client.ts b/src/sync/sync-client.ts new file mode 100644 index 0000000..e3cd60a --- /dev/null +++ b/src/sync/sync-client.ts @@ -0,0 +1,398 @@ +/** + * HTTP sync client using Obsidian's requestUrl API. + */ + +import { requestUrl, RequestUrlParam, RequestUrlResponse } from "obsidian"; +import type { ManifestFileRecord, VaultManifest } from "./sync-types"; + +export interface SyncClientConfig { + serverUrl: string; + auth?: { + username: string; + password: string; + }; + timeout?: number; +} + +export interface PropfindEntry { + href: string; + status: string; + size: number; + lastModified: string; +} + +interface RequestOptions { + headers?: Record; + body?: string | ArrayBuffer; + allowStatuses?: number[]; +} + +export class SyncClient { + private config: SyncClientConfig; + private readonly defaultTimeout = 30000; + + constructor(config: SyncClientConfig) { + this.config = config; + } + + updateConfig(config: SyncClientConfig): void { + this.config = config; + } + + getServerUrl(): string { + return this.normalizeServerUrl(this.config.serverUrl); + } + + async healthCheck(): Promise<{ status: string; vault: string }> { + const response = await this.request("/.clawvault/health", "GET"); + const payload = this.safeJson(response.text); + const record = this.toRecord(payload); + if (!record) { + throw new Error("Unexpected health response"); + } + + const status = this.readString(record, ["status"]) ?? "unknown"; + const vault = this.readString(record, ["vault", "name"]) ?? "unknown"; + return { status, vault }; + } + + async fetchManifest(): Promise { + const response = await this.request("/.clawvault/manifest", "GET"); + const payload = this.safeJson(response.text); + if (!payload) { + throw new Error("Manifest response was empty"); + } + return this.normalizeManifest(payload); + } + + async getFile(remotePath: string): Promise { + const response = await this.request(this.toWebDavPath(remotePath), "GET"); + return response.text; + } + + async getFileBinary(remotePath: string): Promise { + const response = await this.request(this.toWebDavPath(remotePath), "GET"); + return response.arrayBuffer; + } + + async putFile(remotePath: string, content: string): Promise { + await this.putFileInternal(remotePath, content); + } + + async putFileBinary(remotePath: string, content: ArrayBuffer): Promise { + await this.putFileInternal(remotePath, content); + } + + async deleteFile(remotePath: string): Promise { + await this.request(this.toWebDavPath(remotePath), "DELETE", { + allowStatuses: [200, 202, 204, 404], + }); + } + + async propfind(remotePath: string, depth = "1"): Promise { + const response = await this.request(this.toWebDavPath(remotePath), "PROPFIND", { + headers: { + Depth: depth, + "Content-Type": "application/xml; charset=utf-8", + }, + body: ` + + + + + +`, + allowStatuses: [200, 207], + }); + return this.parsePropfind(response.text); + } + + async getFilesBatch(paths: string[]): Promise> { + const results = new Map(); + for (const path of paths) { + results.set(path, await this.getFile(path)); + } + return results; + } + + private async putFileInternal(remotePath: string, content: string | ArrayBuffer): Promise { + await this.ensureRemoteDirectories(remotePath); + await this.request(this.toWebDavPath(remotePath), "PUT", { + headers: { + "Content-Type": "application/octet-stream", + }, + body: content, + allowStatuses: [200, 201, 204], + }); + } + + private async ensureRemoteDirectories(remotePath: string): Promise { + const normalized = this.normalizePath(remotePath); + const segments = normalized.split("/").filter((segment) => segment.length > 0); + if (segments.length <= 1) return; + + let current = ""; + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i]; + if (!segment) continue; + current = current ? `${current}/${segment}` : segment; + await this.request(this.toWebDavPath(current), "MKCOL", { + allowStatuses: [201, 301, 405, 409], + }); + } + } + + private async request( + path: string, + method: string, + options: RequestOptions = {} + ): Promise { + const url = this.buildUrl(path); + const headers = { + ...this.buildAuthHeaders(), + ...options.headers, + }; + + const requestParams: RequestUrlParam = { + url, + method, + headers, + throw: false, + }; + + if (typeof options.body !== "undefined") { + requestParams.body = options.body; + } + + const response = await this.requestWithTimeout(requestParams); + const allowedStatuses = options.allowStatuses ?? []; + const isAllowed = allowedStatuses.includes(response.status); + if (response.status >= 400 && !isAllowed) { + throw new Error(`${method} ${path} failed with ${response.status}`); + } + + return response; + } + + private async requestWithTimeout(params: RequestUrlParam): Promise { + let timeoutHandle: ReturnType | null = null; + const timeoutMs = this.config.timeout ?? this.defaultTimeout; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reject(new Error(`Request timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }); + + try { + return await Promise.race([requestUrl(params), timeoutPromise]); + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + } + } + + private buildAuthHeaders(): Record { + const auth = this.config.auth; + if (!auth || (!auth.username && !auth.password)) { + return {}; + } + + const raw = `${auth.username}:${auth.password}`; + const bytes = new TextEncoder().encode(raw); + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + + return { Authorization: `Basic ${btoa(binary)}` }; + } + + private buildUrl(path: string): string { + const base = this.normalizeServerUrl(this.config.serverUrl); + if (!base) { + throw new Error("Sync server URL is not configured"); + } + + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + return `${base}${normalizedPath}`; + } + + private normalizeServerUrl(url: string): string { + return url.trim().replace(/\/+$/, ""); + } + + private toWebDavPath(remotePath: string): string { + const normalized = this.normalizePath(remotePath); + if (!normalized) { + return "/webdav/"; + } + + const encoded = normalized + .split("/") + .filter((segment) => segment.length > 0) + .map((segment) => encodeURIComponent(segment)) + .join("/"); + return `/webdav/${encoded}`; + } + + private normalizePath(path: string): string { + return path.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/{2,}/g, "/"); + } + + private parsePropfind(xml: string): PropfindEntry[] { + const parser = new DOMParser(); + const document = parser.parseFromString(xml, "application/xml"); + const responses = Array.from(document.getElementsByTagName("response")); + const entries: PropfindEntry[] = []; + + for (const response of responses) { + const href = response.getElementsByTagName("href")[0]?.textContent?.trim() ?? ""; + const status = response.getElementsByTagName("status")[0]?.textContent?.trim() ?? ""; + const sizeText = + response.getElementsByTagName("getcontentlength")[0]?.textContent?.trim() ?? "0"; + const lastModified = + response.getElementsByTagName("getlastmodified")[0]?.textContent?.trim() ?? ""; + const size = Number.parseInt(sizeText, 10); + + entries.push({ + href, + status, + size: Number.isFinite(size) ? size : 0, + lastModified, + }); + } + + return entries; + } + + private safeJson(value: string): unknown { + try { + return JSON.parse(value) as unknown; + } catch { + return null; + } + } + + private normalizeManifest(payload: unknown): VaultManifest { + const source = this.toRecord(payload); + if (!source) { + throw new Error("Manifest payload is not an object"); + } + + const files: ManifestFileRecord[] = []; + const rawFiles = source["files"]; + + if (Array.isArray(rawFiles)) { + for (const rawFile of rawFiles) { + const parsed = this.parseManifestFile(rawFile); + if (parsed) { + files.push(parsed); + } + } + } else { + const fileMap = this.toRecord(rawFiles); + const candidates = fileMap ?? source; + for (const [path, value] of Object.entries(candidates)) { + if (this.isMetadataKey(path)) { + continue; + } + if (!this.looksLikeFilePath(path) && fileMap === null) { + continue; + } + const parsed = this.parseManifestFile(value, path); + if (parsed) { + files.push(parsed); + } + } + } + + const generatedAt = + this.readString(source, ["generatedAt", "timestamp", "updatedAt"]) ?? + new Date().toISOString(); + + return { generatedAt, files }; + } + + private isMetadataKey(key: string): boolean { + return [ + "generatedAt", + "timestamp", + "updatedAt", + "files", + "vault", + "name", + "stats", + "version", + ].includes(key); + } + + private looksLikeFilePath(value: string): boolean { + return value.includes("/") || value.includes(".") || value.startsWith("_"); + } + + private parseManifestFile(rawValue: unknown, keyPath?: string): ManifestFileRecord | null { + const record = this.toRecord(rawValue); + if (!record && !keyPath) { + return null; + } + + const path = this.normalizePath( + this.readString(record, ["path", "file", "name"]) ?? keyPath ?? "" + ); + if (!path) { + return null; + } + + const checksum = this.readString(record, ["checksum", "hash", "sha256"]) ?? ""; + const modified = + this.readString(record, ["modified", "mtime", "updatedAt", "lastModified"]) ?? + new Date(0).toISOString(); + const size = this.readNumber(record, ["size", "bytes"]) ?? 0; + const category = this.readString(record, ["category"]) ?? path.split("/")[0] ?? undefined; + + return { + path, + checksum, + modified, + size, + category, + }; + } + + private readString(source: Record | null, keys: string[]): string | null { + if (!source) return null; + for (const key of keys) { + const value = source[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value; + } + } + return null; + } + + private readNumber(source: Record | null, keys: string[]): number | null { + if (!source) return null; + for (const key of keys) { + const value = source[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number.parseFloat(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + } + return null; + } + + private toRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; + } +} + diff --git a/src/sync/sync-engine.ts b/src/sync/sync-engine.ts new file mode 100644 index 0000000..53801a9 --- /dev/null +++ b/src/sync/sync-engine.ts @@ -0,0 +1,662 @@ +/** + * Core sync orchestrator. + */ + +import { App, Platform } from "obsidian"; +import { SyncClient } from "./sync-client"; +import { ResolvedConflictAction, SyncResolver } from "./sync-resolver"; +import type { + ManifestFileRecord, + SyncConflict, + SyncFileAction, + SyncMode, + SyncPlan, + SyncProgress, + SyncResult, + SyncSettings, + VaultManifest, +} from "./sync-types"; + +interface SyncExecutionOptions { + mode: SyncMode; + onProgress?: (progress: SyncProgress) => void; +} + +interface LocalManifestCacheEntry { + expiresAt: number; + manifest: VaultManifest; +} + +type VaultAdapter = App["vault"]["adapter"]; +type AdapterWithBinary = VaultAdapter & { + readBinary?: (normalizedPath: string) => Promise; + writeBinary?: (normalizedPath: string, data: ArrayBuffer, options?: DataWriteOptions) => Promise; +}; + +interface DataWriteOptions { + ctime?: number; + mtime?: number; +} + +export class SyncEngine { + private app: App; + private client: SyncClient; + private settings: SyncSettings; + private resolver: SyncResolver; + private localManifestCache: LocalManifestCacheEntry | null = null; + private readonly localManifestCacheTtlMs = 10000; + private excludeRegexCache = new Map(); + + constructor(app: App, client: SyncClient, settings: SyncSettings) { + this.app = app; + this.client = client; + this.settings = settings; + this.resolver = new SyncResolver(Platform.isMobile); + } + + updateSettings(settings: SyncSettings): void { + this.settings = settings; + this.localManifestCache = null; + this.excludeRegexCache.clear(); + } + + async planSync(mode: SyncMode = "full", onProgress?: (progress: SyncProgress) => void): Promise { + onProgress?.({ + stage: "planning", + current: 0, + total: 1, + message: "Loading manifests...", + }); + + const [remoteManifest, localManifest] = await Promise.all([ + this.client.fetchManifest(), + this.buildLocalManifest(), + ]); + + return this.diffManifests(localManifest, remoteManifest, mode); + } + + async executeSync( + plan: SyncPlan, + options: SyncExecutionOptions + ): Promise { + const startedAt = Date.now(); + const result: SyncResult = { + pulled: 0, + pushed: 0, + conflicts: 0, + deleted: 0, + unchanged: plan.unchanged.length, + planned: plan, + errors: [], + startedAt, + endedAt: startedAt, + }; + + if (options.mode !== "push") { + await this.applyActions( + plan.toPull, + "pulling", + (path) => this.pullFile(path), + () => { + result.pulled++; + }, + result, + options.onProgress + ); + } + + if (options.mode !== "pull") { + await this.applyActions( + plan.toPush, + "pushing", + (path) => this.pushFile(path), + () => { + result.pushed++; + }, + result, + options.onProgress + ); + } + + if (options.mode === "full" && plan.conflicts.length > 0) { + for (let index = 0; index < plan.conflicts.length; index++) { + const conflict = plan.conflicts[index]; + if (!conflict) continue; + + options.onProgress?.({ + stage: "conflicts", + current: index + 1, + total: plan.conflicts.length, + path: conflict.path, + message: `Resolving conflict ${index + 1}/${plan.conflicts.length}`, + }); + + try { + await this.resolveConflict(conflict); + result.conflicts++; + } catch (error) { + result.errors.push({ + path: conflict.path, + message: error instanceof Error ? error.message : "Unknown conflict error", + }); + } + } + } + + if (options.mode === "full" && plan.toDelete.length > 0) { + await this.applyActions( + plan.toDelete, + "pushing", + (path) => this.client.deleteFile(path), + () => { + result.deleted++; + }, + result, + options.onProgress + ); + } + + result.endedAt = Date.now(); + this.localManifestCache = null; + options.onProgress?.({ + stage: "complete", + current: 1, + total: 1, + message: "Sync completed", + }); + return result; + } + + async sync( + mode: SyncMode = "full", + onProgress?: (progress: SyncProgress) => void + ): Promise { + const plan = await this.planSync(mode, onProgress); + return this.executeSync(plan, { mode, onProgress }); + } + + async buildLocalManifest(): Promise { + if ( + this.localManifestCache && + this.localManifestCache.expiresAt > Date.now() + ) { + return this.localManifestCache.manifest; + } + + const adapter = this.getAdapter(); + const files = await this.walkAllFiles(adapter); + const records: ManifestFileRecord[] = []; + for (const rawPath of files) { + const path = this.normalizePath(rawPath); + if (!path || this.isInternalPath(path)) continue; + + const category = this.getCategoryForPath(path); + if (!this.shouldSyncPath(path, category)) continue; + + const stat = await adapter.stat(path); + if (!stat) continue; + + const data = await this.readLocalBinary(path); + const checksum = await this.sha256(data); + records.push({ + path, + size: stat.size, + checksum, + modified: new Date(stat.mtime).toISOString(), + category: category || undefined, + }); + } + + const manifest: VaultManifest = { + generatedAt: new Date().toISOString(), + files: records, + }; + + this.localManifestCache = { + expiresAt: Date.now() + this.localManifestCacheTtlMs, + manifest, + }; + + return manifest; + } + + private diffManifests( + localManifest: VaultManifest, + remoteManifest: VaultManifest, + mode: SyncMode + ): SyncPlan { + const toPull: SyncFileAction[] = []; + const toPush: SyncFileAction[] = []; + const conflicts: SyncConflict[] = []; + const unchanged: string[] = []; + const toDelete: SyncFileAction[] = []; + + const localMap = this.toFileMap( + localManifest.files.filter((entry) => this.shouldSyncPath(entry.path, entry.category)) + ); + const remoteMap = this.toFileMap( + remoteManifest.files.filter((entry) => this.shouldSyncPath(entry.path, entry.category)) + ); + const allPaths = new Set([ + ...Array.from(localMap.keys()), + ...Array.from(remoteMap.keys()), + ]); + + for (const path of allPaths) { + const local = localMap.get(path); + const remote = remoteMap.get(path); + + if (!local && remote) { + if (mode !== "push") { + toPull.push({ + path, + direction: "pull", + reason: "new remote file", + remoteModified: remote.modified, + size: remote.size, + }); + } + continue; + } + + if (local && !remote) { + if (mode !== "pull") { + toPush.push({ + path, + direction: "push", + reason: "new local file", + localModified: this.toEpoch(local.modified), + size: local.size, + }); + } + continue; + } + + if (!local || !remote) { + continue; + } + + if (local.checksum === remote.checksum && local.checksum.length > 0) { + unchanged.push(path); + continue; + } + + const localMtime = this.toEpoch(local.modified); + const remoteMtime = this.toEpoch(remote.modified); + const bothModifiedSinceLastSync = + this.settings.lastSyncTimestamp > 0 && + localMtime > this.settings.lastSyncTimestamp && + remoteMtime > this.settings.lastSyncTimestamp; + + if (bothModifiedSinceLastSync && mode === "full") { + conflicts.push({ + path, + localModified: localMtime, + remoteModified: remote.modified, + localSize: local.size, + remoteSize: remote.size, + }); + continue; + } + + if (remoteMtime > localMtime && mode !== "push") { + toPull.push({ + path, + direction: "pull", + reason: "remote newer", + localModified: localMtime, + remoteModified: remote.modified, + size: remote.size, + }); + continue; + } + + if (localMtime > remoteMtime && mode !== "pull") { + toPush.push({ + path, + direction: "push", + reason: "local newer", + localModified: localMtime, + remoteModified: remote.modified, + size: local.size, + }); + continue; + } + + if (mode === "full") { + conflicts.push({ + path, + localModified: localMtime, + remoteModified: remote.modified, + localSize: local.size, + remoteSize: remote.size, + }); + } else if (mode === "pull") { + toPull.push({ + path, + direction: "pull", + reason: "checksum mismatch", + localModified: localMtime, + remoteModified: remote.modified, + size: remote.size, + }); + } else { + toPush.push({ + path, + direction: "push", + reason: "checksum mismatch", + localModified: localMtime, + remoteModified: remote.modified, + size: local.size, + }); + } + } + + return { + toPull, + toPush, + conflicts, + toDelete, + unchanged, + }; + } + + private async applyActions( + actions: SyncFileAction[], + stage: "pulling" | "pushing", + handler: (path: string) => Promise, + onSuccess: () => void, + result: SyncResult, + onProgress?: (progress: SyncProgress) => void + ): Promise { + for (let index = 0; index < actions.length; index++) { + const action = actions[index]; + if (!action) continue; + onProgress?.({ + stage, + current: index + 1, + total: actions.length, + path: action.path, + message: `${stage === "pulling" ? "Pulling" : "Pushing"} ${index + 1}/${actions.length}`, + }); + + try { + await handler(action.path); + onSuccess(); + } catch (error) { + result.errors.push({ + path: action.path, + message: error instanceof Error ? error.message : "Unknown sync action error", + }); + } + } + } + + private async resolveConflict(conflict: SyncConflict): Promise { + const resolution: ResolvedConflictAction = this.resolver.resolve( + conflict, + this.settings.conflictStrategy + ); + + if (resolution.preserveLocalCopy) { + await this.renameLocalToConflictCopy(conflict.path); + } + + if (resolution.action.direction === "pull") { + await this.pullFile(conflict.path); + return; + } + + if (resolution.action.direction === "push") { + await this.pushFile(conflict.path); + } + } + + private async pullFile(path: string): Promise { + const binary = await this.client.getFileBinary(path); + await this.ensureLocalDirectory(path); + await this.writeLocalBinary(path, binary); + } + + private async pushFile(path: string): Promise { + const binary = await this.readLocalBinary(path); + await this.client.putFileBinary(path, binary); + } + + private async renameLocalToConflictCopy(path: string): Promise { + const adapter = this.getAdapter(); + if (!(await adapter.exists(path))) { + return; + } + + const renamedPath = await this.nextConflictPath(path); + await this.ensureLocalDirectory(renamedPath); + await adapter.rename(path, renamedPath); + } + + private async nextConflictPath(path: string): Promise { + const adapter = this.getAdapter(); + const normalized = this.normalizePath(path); + const slashIndex = normalized.lastIndexOf("/"); + const folder = slashIndex >= 0 ? normalized.slice(0, slashIndex) : ""; + const fileName = slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized; + const extIndex = fileName.lastIndexOf("."); + const stem = extIndex > 0 ? fileName.slice(0, extIndex) : fileName; + const extension = extIndex > 0 ? fileName.slice(extIndex) : ""; + const date = new Date().toISOString().slice(0, 10); + let candidate = `${stem}.conflict-${date}${extension}`; + let absoluteCandidate = folder ? `${folder}/${candidate}` : candidate; + let suffix = 1; + + while (await adapter.exists(absoluteCandidate)) { + candidate = `${stem}.conflict-${date}-${suffix}${extension}`; + absoluteCandidate = folder ? `${folder}/${candidate}` : candidate; + suffix++; + } + + return absoluteCandidate; + } + + private async walkAllFiles(adapter: VaultAdapter): Promise { + const files: string[] = []; + const queue: string[] = [""]; + + while (queue.length > 0) { + const current = queue.shift(); + if (typeof current === "undefined") { + continue; + } + + const { files: directFiles, folders } = await this.safeList(adapter, current); + for (const file of directFiles) { + files.push(this.normalizePath(file)); + } + for (const folder of folders) { + queue.push(this.normalizePath(folder)); + } + } + + return files; + } + + private async safeList( + adapter: VaultAdapter, + path: string + ): Promise<{ files: string[]; folders: string[] }> { + try { + return await adapter.list(path); + } catch (error) { + if (path === "") { + return adapter.list("/"); + } + throw error; + } + } + + private async ensureLocalDirectory(filePath: string): Promise { + const adapter = this.getAdapter(); + const segments = this.normalizePath(filePath).split("/"); + segments.pop(); + if (segments.length === 0) return; + + let current = ""; + for (const segment of segments) { + if (!segment) continue; + current = current ? `${current}/${segment}` : segment; + if (!(await adapter.exists(current))) { + await adapter.mkdir(current); + } + } + } + + private async readLocalBinary(path: string): Promise { + const adapter = this.getAdapter() as AdapterWithBinary; + if (typeof adapter.readBinary === "function") { + return adapter.readBinary(path); + } + + const text = await adapter.read(path); + return new TextEncoder().encode(text).buffer; + } + + private async writeLocalBinary(path: string, data: ArrayBuffer): Promise { + const adapter = this.getAdapter() as AdapterWithBinary; + if (typeof adapter.writeBinary === "function") { + await adapter.writeBinary(path, data); + return; + } + + const text = new TextDecoder().decode(data); + await adapter.write(path, text); + } + + private async sha256(data: ArrayBuffer): Promise { + const digest = await crypto.subtle.digest("SHA-256", data); + const bytes = new Uint8Array(digest); + let hash = ""; + for (const byte of bytes) { + hash += byte.toString(16).padStart(2, "0"); + } + return hash; + } + + private shouldSyncPath(path: string, categoryOverride?: string): boolean { + const normalized = this.normalizePath(path); + if (!normalized || this.isInternalPath(normalized)) { + return false; + } + + if (this.matchesExcludePatterns(normalized)) { + return false; + } + + const selectedCategories = this.settings.syncCategories; + if (selectedCategories.length === 0) { + return true; + } + + const category = categoryOverride ?? this.getCategoryForPath(normalized); + if (!category) { + // Root-level files should continue syncing unless explicitly excluded. + return true; + } + + return selectedCategories.includes(category); + } + + private matchesExcludePatterns(path: string): boolean { + for (const rawPattern of this.settings.excludePatterns) { + const pattern = rawPattern.trim(); + if (!pattern) continue; + + let regex = this.excludeRegexCache.get(pattern); + if (!regex) { + regex = this.compileGlobPattern(pattern); + this.excludeRegexCache.set(pattern, regex); + } + + if (regex.test(path)) { + return true; + } + } + return false; + } + + private compileGlobPattern(pattern: string): RegExp { + const normalized = this.normalizePath(pattern); + let output = "^"; + for (let i = 0; i < normalized.length; i++) { + const char = normalized[i]; + if (!char) continue; + + if (char === "*") { + const next = normalized[i + 1]; + if (next === "*") { + output += ".*"; + i++; + } else { + output += "[^/]*"; + } + continue; + } + + if (char === "?") { + output += "."; + continue; + } + + if ("\\^$.+()[]{}|".includes(char)) { + output += `\\${char}`; + } else { + output += char; + } + } + output += "$"; + return new RegExp(output); + } + + private getCategoryForPath(path: string): string { + const normalized = this.normalizePath(path); + const first = normalized.split("/")[0] ?? ""; + if (!first || first.startsWith(".")) { + return ""; + } + return first; + } + + private isInternalPath(path: string): boolean { + const normalized = this.normalizePath(path); + return ( + normalized === ".obsidian" || + normalized === ".clawvault" || + normalized.startsWith(".obsidian/") || + normalized.startsWith(".clawvault/") + ); + } + + private normalizePath(path: string): string { + return path.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/{2,}/g, "/"); + } + + private toFileMap(entries: ManifestFileRecord[]): Map { + const map = new Map(); + for (const entry of entries) { + map.set(this.normalizePath(entry.path), { + ...entry, + path: this.normalizePath(entry.path), + }); + } + return map; + } + + private toEpoch(value: string): number { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; + } + + private getAdapter(): VaultAdapter { + return this.app.vault.adapter; + } +} + diff --git a/src/sync/sync-resolver.ts b/src/sync/sync-resolver.ts new file mode 100644 index 0000000..053863c --- /dev/null +++ b/src/sync/sync-resolver.ts @@ -0,0 +1,88 @@ +/** + * Conflict resolution for sync operations. + */ + +import type { ConflictStrategy, SyncConflict, SyncFileAction } from "./sync-types"; + +export interface ResolvedConflictAction { + action: SyncFileAction; + preserveLocalCopy: boolean; +} + +export class SyncResolver { + private readonly isMobile: boolean; + + constructor(isMobile: boolean) { + this.isMobile = isMobile; + } + + resolve(conflict: SyncConflict, strategy: ConflictStrategy): ResolvedConflictAction { + const effectiveStrategy = + strategy === "ask" && this.isMobile ? "newest-wins" : strategy; + + switch (effectiveStrategy) { + case "remote-wins": + return { + action: this.createPullAction(conflict, "conflict: remote wins"), + preserveLocalCopy: false, + }; + case "local-wins": + return { + action: this.createPushAction(conflict, "conflict: local wins"), + preserveLocalCopy: false, + }; + case "keep-both": + return { + action: this.createPullAction(conflict, "conflict: keep both"), + preserveLocalCopy: true, + }; + case "ask": + // Modal-based conflict prompts are not implemented yet. + // Fall back to newest-wins for now. + return this.resolve(conflict, "newest-wins"); + case "newest-wins": + default: + return this.resolveNewest(conflict); + } + } + + private resolveNewest(conflict: SyncConflict): ResolvedConflictAction { + const remoteTime = Date.parse(conflict.remoteModified); + const localTime = conflict.localModified; + + if (Number.isFinite(remoteTime) && remoteTime > localTime) { + return { + action: this.createPullAction(conflict, "conflict: remote newer"), + preserveLocalCopy: false, + }; + } + + return { + action: this.createPushAction(conflict, "conflict: local newer"), + preserveLocalCopy: false, + }; + } + + private createPullAction(conflict: SyncConflict, reason: string): SyncFileAction { + return { + path: conflict.path, + direction: "pull", + reason, + localModified: conflict.localModified, + remoteModified: conflict.remoteModified, + size: conflict.remoteSize, + }; + } + + private createPushAction(conflict: SyncConflict, reason: string): SyncFileAction { + return { + path: conflict.path, + direction: "push", + reason, + localModified: conflict.localModified, + remoteModified: conflict.remoteModified, + size: conflict.localSize, + }; + } +} + diff --git a/src/sync/sync-status-view.ts b/src/sync/sync-status-view.ts new file mode 100644 index 0000000..f578596 --- /dev/null +++ b/src/sync/sync-status-view.ts @@ -0,0 +1,121 @@ +/** + * Shared sync section renderer for the status sidebar. + */ + +import type { SyncRuntimeState } from "./sync-types"; + +export interface SyncStatusViewActions { + onSyncNow: () => void; + onConfigure: () => void; + onRetry: () => void; +} + +export function renderSyncStatusSection( + parent: HTMLElement, + state: SyncRuntimeState, + actions: SyncStatusViewActions +): HTMLElement { + const section = parent.createDiv({ + cls: "clawvault-status-section clawvault-sync-section", + }); + + section.createEl("h4", { text: state.status === "syncing" ? "๐Ÿ”„ Syncing..." : "๐Ÿ”„ Sync" }); + section.createDiv({ + text: `Server: ${formatServerState(state.serverUrl, state.status)}`, + cls: "clawvault-sync-server", + }); + + section.createDiv({ + text: `Last sync: ${formatLastSync(state.lastSyncTimestamp)}`, + cls: "clawvault-sync-last", + }); + + if (state.progress) { + const progress = state.progress; + const detail = progress.total > 0 + ? `${progress.current}/${progress.total}` + : "running"; + const progressText = progress.message + ? `${progress.message}` + : `${capitalize(progress.stage)} ${detail}`; + section.createDiv({ + text: progressText, + cls: "clawvault-sync-progress", + }); + } else if (state.lastSyncStats) { + section.createDiv({ + text: `โ†“ ${state.lastSyncStats.pulled} pulled ยท โ†‘ ${state.lastSyncStats.pushed} pushed ยท โšก ${state.lastSyncStats.conflicts} conflicts`, + cls: "clawvault-sync-summary", + }); + } else if (state.message) { + section.createDiv({ + text: state.message, + cls: "clawvault-sync-message", + }); + } + + const actionsRow = section.createDiv({ cls: "clawvault-sync-actions" }); + if (state.status === "disconnected" || state.status === "error") { + const retryButton = actionsRow.createEl("button", { + text: "Retry", + cls: "clawvault-sync-action-btn", + }); + retryButton.addEventListener("click", () => actions.onRetry()); + + const configureButton = actionsRow.createEl("button", { + text: "Configure", + cls: "clawvault-sync-action-btn", + }); + configureButton.addEventListener("click", () => actions.onConfigure()); + } else { + const syncNowButton = actionsRow.createEl("button", { + text: state.status === "syncing" ? "Syncing..." : "Sync now", + cls: "clawvault-sync-action-btn", + }); + syncNowButton.disabled = state.status === "syncing"; + syncNowButton.addEventListener("click", () => actions.onSyncNow()); + + const configureButton = actionsRow.createEl("button", { + text: "Configure", + cls: "clawvault-sync-action-btn", + }); + configureButton.addEventListener("click", () => actions.onConfigure()); + } + + return section; +} + +function formatServerState(serverUrl: string, status: SyncRuntimeState["status"]): string { + if (!serverUrl.trim()) { + return "not configured"; + } + + const withoutProtocol = serverUrl.replace(/^https?:\/\//, ""); + if (status === "disconnected" || status === "error") { + return `${withoutProtocol} โŒ`; + } + + return `${withoutProtocol} โœ…`; +} + +function formatLastSync(timestamp: number): string { + if (!timestamp || timestamp <= 0) { + return "never"; + } + + const diffMs = Date.now() - timestamp; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return "just now"; + if (diffMins < 60) return `${diffMins} min ago`; + if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`; + if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`; + return new Date(timestamp).toLocaleString(); +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + diff --git a/src/sync/sync-types.ts b/src/sync/sync-types.ts new file mode 100644 index 0000000..7dde4e6 --- /dev/null +++ b/src/sync/sync-types.ts @@ -0,0 +1,130 @@ +/** + * Shared sync types for the ClawVault plugin. + */ + +export type ConflictStrategy = + | "remote-wins" + | "local-wins" + | "newest-wins" + | "keep-both" + | "ask"; + +export type SyncMode = "full" | "pull" | "push"; + +export interface SyncStatsSummary { + pulled: number; + pushed: number; + conflicts: number; +} + +export interface SyncSettings { + // Connection + serverUrl: string; + authUsername: string; + authPassword: string; + + // Behavior + autoSyncEnabled: boolean; + autoSyncInterval: number; // Minutes + syncOnOpen: boolean; + syncOnClose: boolean; + + // Filtering + syncCategories: string[]; // Empty = all categories + excludePatterns: string[]; + + // Conflict resolution + conflictStrategy: ConflictStrategy; + + // State + lastSyncTimestamp: number; + lastSyncStats: SyncStatsSummary | null; +} + +export const DEFAULT_SYNC_SETTINGS: SyncSettings = { + serverUrl: "", + authUsername: "", + authPassword: "", + autoSyncEnabled: false, + autoSyncInterval: 15, + syncOnOpen: true, + syncOnClose: false, + syncCategories: [], + excludePatterns: [], + conflictStrategy: "newest-wins", + lastSyncTimestamp: 0, + lastSyncStats: null, +}; + +export interface ManifestFileRecord { + path: string; + size: number; + checksum: string; + modified: string; + category?: string; +} + +export interface VaultManifest { + generatedAt: string; + files: ManifestFileRecord[]; +} + +export interface SyncFileAction { + path: string; + direction: "pull" | "push" | "delete"; + reason: string; + localModified?: number; + remoteModified?: string; + size?: number; +} + +export interface SyncConflict { + path: string; + localModified: number; + remoteModified: string; + localSize: number; + remoteSize: number; +} + +export interface SyncPlan { + toPull: SyncFileAction[]; + toPush: SyncFileAction[]; + conflicts: SyncConflict[]; + toDelete: SyncFileAction[]; + unchanged: string[]; +} + +export interface SyncProgress { + stage: "planning" | "pulling" | "pushing" | "conflicts" | "complete"; + current: number; + total: number; + path?: string; + message?: string; +} + +export interface SyncError { + path?: string; + message: string; +} + +export interface SyncResult { + pulled: number; + pushed: number; + conflicts: number; + deleted: number; + unchanged: number; + planned: SyncPlan; + errors: SyncError[]; + startedAt: number; + endedAt: number; +} + +export interface SyncRuntimeState { + status: "disconnected" | "idle" | "syncing" | "error"; + serverUrl: string; + message: string; + lastSyncTimestamp: number; + lastSyncStats: SyncStatsSummary | null; + progress: SyncProgress | null; +} + diff --git a/styles.css b/styles.css index 048d6fe..69a8aee 100644 --- a/styles.css +++ b/styles.css @@ -64,6 +64,39 @@ padding: 8px 0; } +.clawvault-sync-section { + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + padding: 10px; + background: var(--background-secondary); + transition: box-shadow 0.2s ease, border-color 0.2s ease; +} + +.clawvault-sync-server, +.clawvault-sync-last, +.clawvault-sync-summary, +.clawvault-sync-progress, +.clawvault-sync-message { + font-size: 0.85em; + color: var(--text-muted); + margin-top: 4px; +} + +.clawvault-sync-actions { + display: flex; + gap: 8px; + margin-top: 8px; +} + +.clawvault-sync-action-btn { + flex: 1; +} + +.clawvault-sync-highlight { + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px var(--interactive-accent-hover); +} + .clawvault-status-list { display: flex; flex-direction: column;