diff --git a/packages/plugin-dev/sync-md/src/background/background.ts b/packages/plugin-dev/sync-md/src/background/background.ts index 4a52ae7ef8..12cf02b37e 100644 --- a/packages/plugin-dev/sync-md/src/background/background.ts +++ b/packages/plugin-dev/sync-md/src/background/background.ts @@ -1,493 +1,20 @@ -import { FileWatcherBatch } from './file-watcher'; -import { SyncConfig } from '../shared/types'; +import { initSyncManager } from './sync/sync-manager'; +import { initUiBridge } from './ui-bridge'; +import { loadLocalConfig } from './local-config'; -// PluginAPI will be available in the execution context +export const initPlugin = (): void => { + console.log('[sync-md] initPlugin called'); -let fileWatcher: FileWatcherBatch | null = null; -let syncDebounceTimer: any = null; + // Initialize UI bridge to handle messages + initUiBridge(); + console.log('[sync-md] UI bridge initialized'); -// Sync state management - single source of truth -const syncState = { - lastSyncTime: 0, - syncInProgress: false, - pendingSyncReason: null as string | null, - isWindowFocused: true, // Assume focused initially + // Load saved config from local storage and start sync if enabled + const config = loadLocalConfig(); + console.log('[sync-md] Loaded config:', config); + + if (config?.filePath) { + // Transform config to match sync-manager expectations + initSyncManager(config); + } }; - -const MIN_SYNC_INTERVAL = 5000; // 5 seconds minimum between syncs -const SYNC_DEBOUNCE_TIME_FOCUSED = 500; // 0.5 seconds when SP is focused -const SYNC_DEBOUNCE_TIME_UNFOCUSED = 2000; // 2 seconds when editing markdown - -// Single sync entry point to prevent race conditions -// eslint-disable-next-line prefer-arrow/prefer-arrow-functions -async function requestSync(reason: string): Promise { - console.log(`[Sync] requestSync called with reason: ${reason}`); - syncState.pendingSyncReason = reason; - - // If already syncing, don't schedule another - if (syncState.syncInProgress) { - console.log(`[Sync] Sync already in progress, queuing: ${reason}`); - return; - } - - // Clear any existing timer - if (syncDebounceTimer) { - console.log(`[Sync] Clearing existing sync timer`); - const clearTimeoutFn = (globalThis as any).clearTimeout || window.clearTimeout; - clearTimeoutFn(syncDebounceTimer); - } - - // Schedule sync with debounce based on window focus - const debounceTime = syncState.isWindowFocused - ? SYNC_DEBOUNCE_TIME_FOCUSED - : SYNC_DEBOUNCE_TIME_UNFOCUSED; - - console.log( - `[Sync] Scheduling sync in ${debounceTime}ms (window ${syncState.isWindowFocused ? 'focused' : 'unfocused'})`, - ); - - // Use globalThis for better compatibility - const timeoutFn = (globalThis as any).setTimeout || window.setTimeout; - syncDebounceTimer = timeoutFn(async () => { - console.log(`[Sync] Debounce timer fired, calling performSyncIfNeeded`); - await performSyncIfNeeded(); - }, debounceTime) as number; -} - -// Actual sync execution with rate limiting -// eslint-disable-next-line prefer-arrow/prefer-arrow-functions -async function performSyncIfNeeded(): Promise { - console.log('[Sync] performSyncIfNeeded called'); - const now = Date.now(); - const timeSinceLastSync = now - syncState.lastSyncTime; - const reason = syncState.pendingSyncReason || 'Unknown'; - - console.log( - `[Sync] Last sync time: ${syncState.lastSyncTime}, current time: ${now}, difference: ${timeSinceLastSync}ms`, - ); - - // Check if we're within rate limit - if (timeSinceLastSync < MIN_SYNC_INTERVAL && syncState.lastSyncTime > 0) { - console.log( - `[Sync] Rate limited: ${reason} - only ${timeSinceLastSync}ms since last sync (min: ${MIN_SYNC_INTERVAL}ms)`, - ); - // Schedule retry after rate limit expires - const retryIn = MIN_SYNC_INTERVAL - timeSinceLastSync + 1000; - setTimeout(() => performSyncIfNeeded(), retryIn); - return; - } - - // Check if we can sync - if (!fileWatcher) { - console.log('[Sync] No file watcher available'); - return; - } - - // Mark as syncing - syncState.syncInProgress = true; - syncState.lastSyncTime = now; - syncState.pendingSyncReason = null; - - console.log(`[Sync] Starting sync: ${reason} (${timeSinceLastSync}ms since last sync)`); - - try { - // Get fresh state from SP - const [tasks, projects] = await Promise.all([ - PluginAPI.getTasks(), - PluginAPI.getAllProjects(), - ]); - - // Convert to state format - const taskState = { - entities: tasks.reduce((acc: any, task: any) => { - acc[task.id] = task; - return acc; - }, {}), - }; - - const projectState = { - entities: projects.reduce((acc: any, project: any) => { - acc[project.id] = project; - return acc; - }, {}), - }; - - // Determine sync source based on reason - let syncSource: 'file' | 'sp' | 'manual' = 'manual'; - if (reason.includes('File changed')) { - syncSource = 'file'; - } else if (reason.includes('Task')) { - syncSource = 'sp'; - } - - // Perform sync with fresh state - await fileWatcher.performSync(taskState, projectState, syncSource); - console.log('[Sync] Sync completed successfully'); - } catch (error) { - console.error('[Sync] Sync error:', error); - } finally { - syncState.syncInProgress = false; - - // If there's a pending sync request, process it - if (syncState.pendingSyncReason) { - console.log(`[Sync] Processing pending sync: ${syncState.pendingSyncReason}`); - setTimeout(() => performSyncIfNeeded(), 1000); - } - } -} - -// Function to register message handler -const registerMessageHandler = (): boolean => { - // PluginAPI should be available in the current scope - if (typeof PluginAPI !== 'undefined' && PluginAPI?.onMessage) { - console.log('Registering message handler with PluginAPI'); - PluginAPI.onMessage(async (message: any) => { - console.log('Background received message:', message); - - try { - let response: Record = { success: true }; - - // Type guard for message - if (typeof message !== 'object' || message === null || !('type' in message)) { - return { success: false, error: 'Invalid message format' }; - } - - const msg = message as { type: string; config?: SyncConfig; filePath?: string }; - - switch (msg.type) { - case 'configUpdated': - console.log('[Background] Config updated:', msg.config); - if (fileWatcher) { - console.log('[Background] Stopping existing file watcher'); - fileWatcher.stop(); - fileWatcher = null; - } - - if (msg.config?.enabled) { - console.log( - '[Background] Creating new file watcher with config:', - msg.config, - ); - fileWatcher = new FileWatcherBatch({ - config: msg.config, - onSync: (result) => { - // Handle different sync events - if (result.type === 'fileChanged') { - console.log('[Sync] File changed, requesting sync'); - requestSync('File changed'); - } else { - console.log('[Sync] Completed:', result); - // Send sync result to UI - window.parent.postMessage( - { - type: 'SYNC_COMPLETED', - result, - }, - '*', - ); - } - }, - onError: (error) => { - console.error('[Sync] Error:', error); - window.parent.postMessage( - { - type: 'SYNC_ERROR', - error: error.message, - }, - '*', - ); - }, - }); - - // Start file watcher - console.log('[Background] Starting file watcher'); - await fileWatcher.start(true); // Skip initial sync - - // Request initial sync - console.log('[Background] Requesting initial sync'); - await requestSync('Initial configuration'); - } else { - console.log('[Background] Config disabled or not provided'); - } - break; - - case 'testFile': - const { filePath } = msg; - if (!filePath) { - response = { success: false, error: 'No file path provided' }; - } else { - try { - // Test file access - const content = await readFileContent(filePath); - const lines = content.split('\n').slice(0, 10); - response = { - success: true, - preview: - lines.join('\n') + (content.split('\n').length > 10 ? '\n...' : ''), - }; - } catch (error) { - response = { success: false, error: (error as any)?.message }; - } - } - break; - - case 'syncNow': - console.log('[Background] Received syncNow message'); - if (fileWatcher) { - console.log('[Background] File watcher exists, triggering sync'); - // Force immediate sync by clearing rate limit - syncState.lastSyncTime = 0; - await requestSync('Manual sync'); - response = { success: true }; - } else { - console.log('[Background] No file watcher initialized'); - response = { success: false, error: 'File watcher not initialized' }; - } - break; - - case 'getSyncInfo': - if (fileWatcher) { - const info = await fileWatcher.getSyncInfo(); - response = { - ...info, - success: true, - lastSyncTime: syncState.lastSyncTime, - syncInProgress: syncState.syncInProgress, - }; - } else { - response = { - success: true, - lastSyncTime: syncState.lastSyncTime, - taskCount: 0, - isWatching: false, - syncInProgress: syncState.syncInProgress, - }; - } - break; - - case 'getProjects': - // Always get fresh data - const projects = await PluginAPI.getAllProjects(); - response = { success: true, projects }; - break; - - case 'getTasks': - // Always get fresh data - const tasks = await PluginAPI.getTasks(); - response = { success: true, tasks }; - break; - - case 'checkDesktopMode': - // Check if we're in Electron environment with Node.js capabilities - response = { - success: true, - isDesktop: typeof PluginAPI?.executeNodeScript === 'function', - }; - break; - - default: - response = { success: false, error: `Unknown message type: ${msg.type}` }; - } - - return response; - } catch (error) { - console.error('Error handling message:', error); - // @ts-ignore - return { success: false, error: error.message }; - } - }); - return true; - } - return false; -}; - -// eslint-disable-next-line prefer-arrow/prefer-arrow-functions -async function readFileContent(filePath: string): Promise { - if (!PluginAPI?.executeNodeScript) { - throw new Error('Node script execution not available'); - } - - const result = await PluginAPI.executeNodeScript({ - script: ` - const fs = require('fs'); - const path = require('path'); - - try { - const absolutePath = path.resolve(args[0]); - if (!fs.existsSync(absolutePath)) { - return { success: false, error: 'File not found' }; - } - const content = fs.readFileSync(absolutePath, 'utf8'); - return { success: true, content }; - } catch (error) { - return { success: false, error: error.message }; - } - `, - args: [filePath], - timeout: 5000, - }); - - if (!result.success) { - throw new Error(result.error || 'Failed to execute node script'); - } - - if (!result.result?.success) { - throw new Error(result.result?.error || 'Failed to read file'); - } - - return result.result.content; -} - -// Plugin initialization function - will be called with PluginAPI in scope -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type,prefer-arrow/prefer-arrow-functions -async function initPlugin(): Promise { - console.log('Sync-MD plugin initializing...'); - - // Check if PluginAPI exists in the current scope - if (typeof PluginAPI === 'undefined') { - console.error('[Plugin] PluginAPI is not available in the current scope'); - return; - } - - // Set up window focus tracking - if (PluginAPI.onWindowFocusChange) { - PluginAPI.onWindowFocusChange((isFocused: boolean) => { - syncState.isWindowFocused = isFocused; - console.log(`[Sync] Window focus changed: ${isFocused ? 'focused' : 'unfocused'}`); - }); - } else { - console.warn('[Sync] Window focus tracking not available in PluginAPI'); - } - - // Check if we're in desktop mode (Electron) with Node.js capabilities - const isDesktop = typeof PluginAPI.executeNodeScript === 'function'; - if (!isDesktop) { - console.warn('[Plugin] Sync.md plugin requires desktop mode for file operations'); - console.warn('[Plugin] Running in limited mode - file sync features are disabled'); - console.warn('[Plugin] PluginAPI methods available:', Object.keys(PluginAPI || {})); - } else { - // Test executeNodeScript permission - try { - console.log('[Plugin] Testing executeNodeScript permission...'); - const testResult = await PluginAPI.executeNodeScript!({ - script: `return { success: true, test: 'permission granted' };`, - args: [], - timeout: 1000, - }); - console.log('[Plugin] executeNodeScript permission test result:', testResult); - } catch (error) { - console.error('[Plugin] executeNodeScript permission test failed:', error); - console.error( - '[Plugin] This plugin requires Node.js execution permissions to work properly.', - ); - } - } - - // Register message handler immediately - const registered = registerMessageHandler(); - if (!registered) { - console.error('[Plugin] Failed to register message handler'); - console.error( - '[Plugin] PluginAPI:', - typeof PluginAPI, - 'onMessage:', - typeof PluginAPI?.onMessage, - ); - } else { - console.log('[Plugin] Message handler registered successfully'); - } - - // Register hooks for task updates with better logging - if (PluginAPI?.registerHook) { - console.log('[Sync] Registering task update hooks'); - - // Track hook call frequency for debugging - let hookCallCount = 0; - let lastHookCall = 0; - - // Single hook for all changes to prevent duplicate syncs - PluginAPI.registerHook('anyTaskUpdate', (payload: any) => { - const now = Date.now(); - const timeSinceLastCall = now - lastHookCall; - hookCallCount++; - - // Log hook frequency for debugging - if (hookCallCount % 10 === 0) { - console.log( - `[Hook] Task update hook called ${hookCallCount} times, avg interval: ${timeSinceLastCall}ms`, - ); - } - - lastHookCall = now; - - // Request sync with debouncing - console.log(`[Hook] Task update action: ${payload.action}`); - requestSync(`Task ${payload.action}`); - }); - } - - // Check if we have saved config - const savedData = await PluginAPI?.loadSyncedData?.(); - if (savedData) { - try { - const config: SyncConfig = JSON.parse(savedData); - if (config.enabled) { - // Only create file watcher if we have Node.js capabilities - if (typeof PluginAPI?.executeNodeScript === 'function') { - fileWatcher = new FileWatcherBatch({ - config, - onSync: (result) => { - // Handle different sync events - if (result.type === 'fileChanged') { - console.log('[Sync] File changed, requesting sync'); - requestSync('File changed'); - } else { - console.log('[Sync] Auto-sync completed:', result); - } - }, - onError: (error) => { - console.error('[Sync] Auto-sync error:', error); - }, - }); - - console.log('[Sync] Starting file watcher from saved config'); - await fileWatcher!.start(true); // Skip initial sync - - // Request initial sync - await requestSync('Startup sync'); - } else { - console.warn( - '[Sync] Cannot start file watcher - Node.js execution not available', - ); - console.warn( - '[Sync] Plugin may need to request permissions or run in desktop mode', - ); - } - } - } catch (error) { - console.error('Failed to load saved config:', error); - } - } -} - -// The plugin runner will execute this code with PluginAPI in scope -// We need to ensure initPlugin is called -console.log('[Plugin] Sync-MD plugin code loaded'); - -// Check if we're in the plugin execution context -if (typeof PluginAPI !== 'undefined') { - console.log('[Plugin] PluginAPI is available, initializing plugin...'); - initPlugin().catch((error) => { - console.error('[Plugin] Failed to initialize:', error); - }); -} else { - console.error('[Plugin] PluginAPI not found in execution context'); -} - -// Also expose initPlugin for the module loader -if (typeof window !== 'undefined') { - (window as any).initPlugin = initPlugin; - // Export for the IIFE wrapper - (window as any).SyncMdPlugin = { initPlugin }; -} -export { initPlugin }; diff --git a/packages/plugin-dev/sync-md/src/background/config.const.ts b/packages/plugin-dev/sync-md/src/background/config.const.ts new file mode 100644 index 0000000000..08b0cf6a9e --- /dev/null +++ b/packages/plugin-dev/sync-md/src/background/config.const.ts @@ -0,0 +1,3 @@ +export const SYNC_DEBOUNCE_MS = 500; +export const SYNC_DEBOUNCE_MS_UNFOCUSED = 15000; // 15 seconds when SP is not focused +export const FILE_WATCH_POLL_INTERVAL_MS = 1000; diff --git a/packages/plugin-dev/sync-md/src/background/helper/file-utils.ts b/packages/plugin-dev/sync-md/src/background/helper/file-utils.ts new file mode 100644 index 0000000000..5d50156d94 --- /dev/null +++ b/packages/plugin-dev/sync-md/src/background/helper/file-utils.ts @@ -0,0 +1,138 @@ +interface NodeScriptResult { + success: boolean; + content?: string | null; + mtime?: string | null; + error?: string; +} + +export const readTasksFile = async (filePath: string): Promise => { + console.log('[sync-md] readTasksFile called with filePath:', filePath); + if (!PluginAPI.executeNodeScript) { + throw new Error('File operations are only available in the desktop version'); + } + + const result = await PluginAPI.executeNodeScript({ + script: ` + const fs = require('fs'); + const path = require('path'); + + try { + const absolutePath = path.resolve(args[0]); + const content = fs.readFileSync(absolutePath, 'utf8'); + return { success: true, content }; + } catch (error) { + if (error.code === 'ENOENT') { + return { success: true, content: null }; + } + return { success: false, error: error.message }; + } + `, + args: [filePath], + timeout: 5000, + }); + + const nodeResult = result.result as NodeScriptResult; + if (!result.success || !nodeResult?.success) { + throw new Error(nodeResult?.error || result.error || 'Failed to read file'); + } + + return nodeResult.content; +}; + +export const writeTasksFile = async ( + filePath: string, + content: string, +): Promise => { + if (!PluginAPI.executeNodeScript) { + throw new Error('File operations are only available in the desktop version'); + } + + const result = await PluginAPI.executeNodeScript({ + script: ` + const fs = require('fs'); + const path = require('path'); + + try { + const absolutePath = path.resolve(args[0]); + const dirPath = path.dirname(absolutePath); + + // Ensure directory exists + fs.mkdirSync(dirPath, { recursive: true }); + + fs.writeFileSync(absolutePath, args[1], 'utf8'); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + `, + args: [filePath, content], + timeout: 5000, + }); + + const nodeResult = result.result as NodeScriptResult; + if (!result.success || !nodeResult?.success) { + throw new Error(nodeResult?.error || result.error || 'Failed to write file'); + } +}; + +export const getFileStats = async (filePath: string): Promise<{ mtime: Date } | null> => { + if (!PluginAPI.executeNodeScript) { + throw new Error('File operations are only available in the desktop version'); + } + + const result = await PluginAPI.executeNodeScript({ + script: ` + const fs = require('fs'); + const path = require('path'); + + try { + const absolutePath = path.resolve(args[0]); + const stats = fs.statSync(absolutePath); + return { success: true, mtime: stats.mtime.toISOString() }; + } catch (error) { + if (error.code === 'ENOENT') { + return { success: true, mtime: null }; + } + return { success: false, error: error.message }; + } + `, + args: [filePath], + timeout: 5000, + }); + + const nodeResult = result.result as NodeScriptResult; + if (!result.success || !nodeResult?.success) { + throw new Error(nodeResult?.error || result.error || 'Failed to get file stats'); + } + + return nodeResult.mtime ? { mtime: new Date(nodeResult.mtime) } : null; +}; + +export const ensureDirectoryExists = async (filePath: string): Promise => { + if (!PluginAPI.executeNodeScript) { + throw new Error('File operations are only available in the desktop version'); + } + + const result = await PluginAPI.executeNodeScript({ + script: ` + const fs = require('fs'); + const path = require('path'); + + try { + const absolutePath = path.resolve(args[0]); + const dirPath = path.dirname(absolutePath); + fs.mkdirSync(dirPath, { recursive: true }); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + `, + args: [filePath], + timeout: 5000, + }); + + const nodeResult = result.result as NodeScriptResult; + if (!result.success || !nodeResult?.success) { + throw new Error(nodeResult?.error || result.error || 'Failed to create directory'); + } +}; diff --git a/packages/plugin-dev/sync-md/src/background/helper/node-file-helper.ts b/packages/plugin-dev/sync-md/src/background/helper/node-file-helper.ts new file mode 100644 index 0000000000..cd38ec1766 --- /dev/null +++ b/packages/plugin-dev/sync-md/src/background/helper/node-file-helper.ts @@ -0,0 +1,37 @@ +// Helper functions for file operations using Node.js via PluginAPI + +export const readFileContent = async (filePath: string): Promise => { + if (!PluginAPI?.executeNodeScript) { + throw new Error('Node script execution not available'); + } + + const result = await PluginAPI.executeNodeScript({ + script: ` + const fs = require('fs'); + const path = require('path'); + + try { + const absolutePath = path.resolve(args[0]); + if (!fs.existsSync(absolutePath)) { + return { success: false, error: 'File not found' }; + } + const content = fs.readFileSync(absolutePath, 'utf8'); + return { success: true, content }; + } catch (error) { + return { success: false, error: error.message }; + } + `, + args: [filePath], + timeout: 5000, + }); + + if (!result.success) { + throw new Error(result.error || 'Failed to execute node script'); + } + + if (!result.result?.success) { + throw new Error(result.result?.error || 'Failed to read file'); + } + + return result.result.content; +}; diff --git a/packages/plugin-dev/sync-md/src/background/local-config.ts b/packages/plugin-dev/sync-md/src/background/local-config.ts new file mode 100644 index 0000000000..dcd9cdab79 --- /dev/null +++ b/packages/plugin-dev/sync-md/src/background/local-config.ts @@ -0,0 +1,28 @@ +export interface LocalUserCfg { + filePath: string; + projectId: string; +} + +// Storage key for local config +const LOCAL_STORAGE_KEY = 'sync-md-config'; + +export const loadLocalConfig = (): LocalUserCfg | null => { + try { + const savedData = localStorage.getItem(LOCAL_STORAGE_KEY); + if (savedData) { + return JSON.parse(savedData) as LocalUserCfg; + } + return null; + } catch (error) { + console.error('[sync-md] Failed to load config:', error); + return null; + } +}; + +export const saveLocalConfig = (config: LocalUserCfg): void => { + try { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(config)); + } catch (error) { + console.error('[sync-md] Failed to save config:', error); + } +}; diff --git a/packages/plugin-dev/sync-md/src/background/sync/file-watcher.ts b/packages/plugin-dev/sync-md/src/background/sync/file-watcher.ts new file mode 100644 index 0000000000..19e031c48a --- /dev/null +++ b/packages/plugin-dev/sync-md/src/background/sync/file-watcher.ts @@ -0,0 +1,81 @@ +import { FILE_WATCH_POLL_INTERVAL_MS } from '../config.const'; + +interface NodeScriptResult { + success: boolean; + mtime?: string | null; + error?: string; +} + +let watchInterval: number | null = null; +let lastMtime: Date | null = null; + +export const startFileWatcher = (filePath: string, onChange: () => void): void => { + stopFileWatcher(); + + if (!PluginAPI.executeNodeScript) { + console.warn('File watching is only available in the desktop version'); + return; + } + + // Get initial mtime + getFileMtime(filePath) + .then((mtime) => { + lastMtime = mtime; + }) + .catch(() => { + lastMtime = null; + }); + + // Poll for changes + watchInterval = window.setInterval(async () => { + try { + const currentMtime = await getFileMtime(filePath); + if (lastMtime && currentMtime && currentMtime.getTime() !== lastMtime.getTime()) { + lastMtime = currentMtime; + onChange(); + } else if (!lastMtime && currentMtime) { + lastMtime = currentMtime; + } + } catch (error) { + // File might have been deleted + lastMtime = null; + } + }, FILE_WATCH_POLL_INTERVAL_MS); +}; + +export const stopFileWatcher = (): void => { + if (watchInterval) { + clearInterval(watchInterval); + watchInterval = null; + } + lastMtime = null; +}; + +const getFileMtime = async (filePath: string): Promise => { + const result = await PluginAPI.executeNodeScript!({ + script: ` + const fs = require('fs'); + const path = require('path'); + + try { + const absolutePath = path.resolve(args[0]); + const stats = fs.statSync(absolutePath); + return { success: true, mtime: stats.mtime.toISOString() }; + } catch (error) { + if (error.code === 'ENOENT') { + return { success: true, mtime: null }; + } + return { success: false, error: error.message }; + } + `, + args: [filePath], + timeout: 5000, + }); + + const nodeResult = result.result as NodeScriptResult; + if (!result.success || !nodeResult?.success) { + throw new Error(nodeResult?.error || result.error || 'Failed to get file stats'); + } + + return nodeResult.mtime ? new Date(nodeResult.mtime) : null; +}; diff --git a/packages/plugin-dev/sync-md/src/background/sync/generate-task-operations.ts b/packages/plugin-dev/sync-md/src/background/sync/generate-task-operations.ts new file mode 100644 index 0000000000..aa0650d0ea --- /dev/null +++ b/packages/plugin-dev/sync-md/src/background/sync/generate-task-operations.ts @@ -0,0 +1,260 @@ +import { + BatchOperation, + BatchTaskCreate, + BatchTaskDelete, + BatchTaskReorder, + BatchTaskUpdate, + Task, +} from '@super-productivity/plugin-api'; +import { ParsedTask } from './markdown-parser'; + +/** + * Generate batch operations to sync markdown tasks to Super Productivity + */ +export const generateTaskOperations = ( + mdTasks: ParsedTask[], + spTasks: Task[], + projectId: string, +): BatchOperation[] => { + const operations: BatchOperation[] = []; + + // Create maps for easier lookup + const spById = new Map(); + const spByTitle = new Map(); + const mdById = new Map(); + const mdByTitle = new Map(); + + // Build SP maps + spTasks.forEach((task) => { + spById.set(task.id!, task); + // Only map by title if no duplicate titles + if (!spByTitle.has(task.title)) { + spByTitle.set(task.title, task); + } + }); + + // Build MD maps and check for duplicates + const duplicateIds = new Set(); + const firstOccurrence = new Map(); + + mdTasks.forEach((mdTask) => { + const checkId = mdTask.id; + if (checkId) { + if (firstOccurrence.has(checkId)) { + duplicateIds.add(checkId); + console.warn( + `[sync-md] Duplicate task ID found: ${checkId} at line ${mdTask.line} (first occurrence at line ${firstOccurrence.get(checkId)})`, + ); + } else { + firstOccurrence.set(checkId, mdTask.line); + mdById.set(checkId, mdTask); + } + } + if (!mdByTitle.has(mdTask.title)) { + mdByTitle.set(mdTask.title, mdTask); + } + }); + + // Track which SP tasks we've seen + const processedSpIds = new Set(); + + // First pass: process MD tasks + mdTasks.forEach((mdTask) => { + // Check if this task has a duplicate ID + const effectiveId = mdTask.id && !duplicateIds.has(mdTask.id) ? mdTask.id : null; + + if (effectiveId) { + const spTask = spById.get(effectiveId); + if (spTask) { + processedSpIds.add(spTask.id!); + + // Update existing task + const updates: Record = {}; + if (spTask.title !== mdTask.title) { + updates.title = mdTask.title; + } + if (spTask.isDone !== mdTask.completed) { + updates.isDone = mdTask.completed; + } + + // Handle notes + const mdNotes = mdTask.notes || ''; + if ((spTask.notes || '') !== mdNotes) { + updates.notes = mdNotes; + } + + // Handle parent relationship + if (spTask.parentId !== mdTask.parentId) { + updates.parentId = mdTask.parentId; + } + + if (Object.keys(updates).length > 0) { + operations.push({ + type: 'update', + taskId: spTask.id!, + updates, + } as BatchTaskUpdate); + } + } else { + // Create new task with the specified ID + operations.push({ + type: 'create', + tempId: `temp_${mdTask.line}`, + data: { + title: mdTask.title, + isDone: mdTask.completed, + notes: mdTask.notes, + parentId: mdTask.parentId, + }, + } as BatchTaskCreate); + } + } else { + // No ID - try to match by title + const spTask = spByTitle.get(mdTask.title); + if (spTask && !processedSpIds.has(spTask.id!)) { + processedSpIds.add(spTask.id!); + console.log(`[sync-md] Matched task by title: "${mdTask.title}" -> ${spTask.id}`); + + // Update existing task + const updates: Record = {}; + if (spTask.isDone !== mdTask.completed) { + updates.isDone = mdTask.completed; + } + + const mdNotes = mdTask.notes || ''; + if ((spTask.notes || '') !== mdNotes) { + updates.notes = mdNotes; + } + + // Handle parent relationship + if (spTask.parentId !== mdTask.parentId) { + updates.parentId = mdTask.parentId; + } + + if (Object.keys(updates).length > 0) { + operations.push({ + type: 'update', + taskId: spTask.id!, + updates, + } as BatchTaskUpdate); + } + } else { + // Create new task + operations.push({ + type: 'create', + tempId: `temp_${mdTask.line}`, + data: { + title: mdTask.title, + isDone: mdTask.completed, + notes: mdTask.notes, + parentId: mdTask.parentId, + }, + } as BatchTaskCreate); + } + } + }); + + // Second pass: delete tasks that exist in SP but not in MD + spTasks.forEach((spTask) => { + if (!processedSpIds.has(spTask.id!) && spTask.projectId === projectId) { + operations.push({ + type: 'delete', + taskId: spTask.id!, + } as BatchTaskDelete); + } + }); + + // Third pass: handle reordering if needed + const mdRootTasks = mdTasks.filter((t) => !t.isSubtask); + const mdTaskIds = mdRootTasks.map((t) => { + if (t.id && spById.has(t.id)) { + return t.id; + } + const spTask = spByTitle.get(t.title); + return spTask ? spTask.id! : `temp_${t.line}`; + }); + + if (mdTaskIds.length > 0) { + // Log the order for debugging + console.log('[sync-md] Reorder operation - task order from markdown:'); + mdRootTasks.forEach((task, index) => { + console.log( + ` ${index + 1}. Line ${task.line}: ${task.title} (${mdTaskIds[index]})`, + ); + }); + + operations.push({ + type: 'reorder', + taskIds: mdTaskIds, + } as BatchTaskReorder); + } + + // Fourth pass: handle subtask ordering + // Group subtasks by parent, maintaining their order from the markdown file + const subtasksByParent = new Map(); + mdTasks + .filter((t) => t.isSubtask && t.parentId) + .forEach((subtask) => { + if (!subtasksByParent.has(subtask.parentId!)) { + subtasksByParent.set(subtask.parentId!, []); + } + subtasksByParent.get(subtask.parentId!)!.push(subtask); + }); + + // Sort subtasks by line number to maintain order from file + subtasksByParent.forEach((subtasks) => { + subtasks.sort((a, b) => a.line - b.line); + }); + + // Update parent tasks with correct subtask order + subtasksByParent.forEach((subtasks, parentId) => { + const parentTask = spById.get(parentId); + if (parentTask) { + // Map subtasks to their IDs + const orderedSubtaskIds = subtasks + .map((subtask) => { + if (subtask.id && spById.has(subtask.id)) { + return subtask.id; + } + const spSubtask = spByTitle.get(subtask.title); + return spSubtask ? spSubtask.id! : null; + }) + .filter((id) => id !== null) as string[]; + + // Check if the order is different + const currentSubtaskIds = parentTask.subTaskIds || []; + const orderChanged = + currentSubtaskIds.length !== orderedSubtaskIds.length || + currentSubtaskIds.some((id, index) => id !== orderedSubtaskIds[index]); + + if (orderChanged) { + // Find if we already have an update operation for this parent + const existingUpdateIndex = operations.findIndex( + (op) => op.type === 'update' && op.taskId === parentId, + ); + + if (existingUpdateIndex >= 0) { + // Add subTaskIds to existing update + (operations[existingUpdateIndex] as BatchTaskUpdate).updates.subTaskIds = + orderedSubtaskIds; + } else { + // Create new update operation + operations.push({ + type: 'update', + taskId: parentId, + updates: { + subTaskIds: orderedSubtaskIds, + }, + } as BatchTaskUpdate); + } + } + } + }); + + // Ensure reorder operations come last + // This is important so that newly created tasks are ordered correctly + const reorderOps = operations.filter((op) => op.type === 'reorder'); + const otherOps = operations.filter((op) => op.type !== 'reorder'); + + return [...otherOps, ...reorderOps]; +}; diff --git a/packages/plugin-dev/sync-md/src/background/sync/markdown-parser.ts b/packages/plugin-dev/sync-md/src/background/sync/markdown-parser.ts new file mode 100644 index 0000000000..81fed23ed1 --- /dev/null +++ b/packages/plugin-dev/sync-md/src/background/sync/markdown-parser.ts @@ -0,0 +1,203 @@ +import { Task } from '@super-productivity/plugin-api'; + +export interface ParsedTask { + line: number; + indent: number; + completed: boolean; + id: string | null; + title: string; + originalLine: string; + parentId?: string | null; + isSubtask: boolean; + depth: number; + notes?: string; +} + +export interface TaskParseResult { + tasks: ParsedTask[]; + errors: string[]; +} + +/** + * Detect the base indentation size used in the markdown + */ +const detectIndentSize = (lines: string[]): number => { + let minIndent = Infinity; + + for (const line of lines) { + const match = line.match(/^(\s+)- \[/); + if (match && match[1].length > 0) { + minIndent = Math.min(minIndent, match[1].length); + } + } + + return minIndent === Infinity ? 2 : minIndent; +}; + +/** + * Parse markdown content into task objects + * Handles tasks and subtasks (first two levels) and converts deeper levels to notes + */ +export const parseMarkdown = (content: string): ParsedTask[] => { + const result = parseMarkdownWithErrors(content); + return result.tasks; +}; + +/** + * Parse markdown content into task objects with error collection + * Handles tasks and subtasks (first two levels) and converts deeper levels to notes + */ +export const parseMarkdownWithErrors = (content: string): TaskParseResult => { + const lines = content.split('\n'); + const tasks: ParsedTask[] = []; + const errors: string[] = []; + const parentStack: Array<{ + indent: number; + id: string | null; + taskIndex: number; + }> = []; + const seenIds = new Set(); + + const detectedIndentSize = detectIndentSize(lines); + console.log(`[sync-md] Detected indent size: ${detectedIndentSize} spaces`); + + let currentTaskIndex = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Match task line: - [ ] or - [x] with optional ID comment at the beginning + const taskMatch = line.match(/^(\s*)- \[([ x])\]\s*(?:\s*)?(.*)$/); + + if (taskMatch) { + const [, indent, completed, id, title] = taskMatch; + const indentLevel = indent.length; + const depth = indentLevel === 0 ? 0 : Math.floor(indentLevel / detectedIndentSize); + + // Check for empty task title + if (!title.trim()) { + errors.push(`Skipping task with empty title at line ${i + 1}`); + continue; + } + + // Only process tasks at depth 0 (parent) and 1 (subtask) + if (depth <= 1) { + const isSubtask = depth === 1; + let parentId: string | null = null; + + if (isSubtask) { + // Find parent from stack + for (let j = parentStack.length - 1; j >= 0; j--) { + if (parentStack[j].indent < indentLevel) { + parentId = parentStack[j].id; + break; + } + } + } + + // Clean up stack for current level + while ( + parentStack.length > 0 && + parentStack[parentStack.length - 1].indent >= indentLevel + ) { + parentStack.pop(); + } + + // Check for duplicate IDs + const taskId = id?.trim() || null; + if (taskId && seenIds.has(taskId)) { + errors.push(`Duplicate task ID found: ${taskId} at line ${i + 1}`); + console.warn( + `[sync-md] Skipping duplicate task ID: ${taskId} at line ${i + 1}`, + ); + // Skip this task to avoid issues + continue; + } + if (taskId) { + seenIds.add(taskId); + } + + // Create task + const task: ParsedTask = { + line: i, + indent: indentLevel, + completed: completed === 'x', + id: taskId, + title: title.trim(), + originalLine: line, + parentId, + isSubtask, + depth, + }; + + tasks.push(task); + currentTaskIndex = tasks.length - 1; + + // Add to parent stack + parentStack.push({ + indent: indentLevel, + id: task.id, + taskIndex: currentTaskIndex, + }); + } else { + // Depth 2+ - convert to notes content for the current task + if (currentTaskIndex >= 0) { + // For subtasks, we need to remove the subtask's indentation (2 spaces) + // to get the note content with correct relative indentation + const currentTask = tasks[currentTaskIndex]; + const baseIndent = currentTask.isSubtask + ? currentTask.indent + detectedIndentSize + : detectedIndentSize * 2; + const noteLine = + line.length > baseIndent ? line.substring(baseIndent) : line.trim(); + if (!tasks[currentTaskIndex].notes) { + tasks[currentTaskIndex].notes = ''; + } + tasks[currentTaskIndex].notes += + (tasks[currentTaskIndex].notes ? '\n' : '') + noteLine; + } + } + } else if (line.trim() && currentTaskIndex >= 0) { + // Non-task line - add to current task's notes if it's not empty + // Remove the expected indentation for notes under the current task + const currentTask = tasks[currentTaskIndex]; + const baseIndent = currentTask.isSubtask + ? currentTask.indent + detectedIndentSize + : detectedIndentSize; + const noteLine = + line.length > baseIndent ? line.substring(baseIndent) : line.trim(); + + if (!tasks[currentTaskIndex].notes) { + tasks[currentTaskIndex].notes = ''; + } + tasks[currentTaskIndex].notes += + (tasks[currentTaskIndex].notes ? '\n' : '') + noteLine; + } + } + + return { tasks, errors }; +}; + +/** + * Generate a unique task ID + */ +export const generateTaskId = (): string => { + return 'sp-' + Date.now().toString(36) + Math.random().toString(36).substr(2, 9); +}; + +/** + * Convert markdown parsed tasks to SP format for comparison + */ +export const convertParsedTasksToSP = ( + parsedTasks: ParsedTask[], + projectId: string, +): Partial[] => { + return parsedTasks.map((task) => ({ + id: task.id || generateTaskId(), + title: task.title, + isDone: task.completed, + notes: task.notes || '', + projectId, + parentId: task.parentId || null, + })); +}; diff --git a/packages/plugin-dev/sync-md/src/background/sync/md-to-sp.ts b/packages/plugin-dev/sync-md/src/background/sync/md-to-sp.ts new file mode 100644 index 0000000000..46ab7c64bd --- /dev/null +++ b/packages/plugin-dev/sync-md/src/background/sync/md-to-sp.ts @@ -0,0 +1,46 @@ +import { parseMarkdown } from './markdown-parser'; +import { generateTaskOperations } from './generate-task-operations'; +// import { Task } from '@super-productivity/plugin-api'; + +/** + * Replicate markdown content to Super Productivity tasks + * Uses the new generateTaskOperations function for proper bidirectional sync + */ +export const mdToSp = async ( + markdownContent: string, + projectId: string, +): Promise => { + const parsedTasks = parseMarkdown(markdownContent); + + if (parsedTasks.length === 0) { + return; + } + + // Get current state + const currentTasks = await PluginAPI.getTasks(); + const currentProjects = await PluginAPI.getAllProjects(); + + if (!currentProjects.find((p) => p.id === projectId)) { + console.warn(`[sync-md] Project ${projectId} not found, skipping sync`); + return; + } + + // Filter tasks for the specific project + const projectTasks = currentTasks.filter((task) => task.projectId === projectId); + + // Generate operations using the new sync logic + const operations = generateTaskOperations(parsedTasks, projectTasks, projectId); + + // Execute batch operations + if (operations.length > 0) { + console.log( + `[sync-md] Executing ${operations.length} sync operations for project ${projectId}`, + ); + + // Use the operations directly - they already match the expected BatchOperation format + await PluginAPI.batchUpdateForProject({ + projectId, + operations, + }); + } +}; diff --git a/packages/plugin-dev/sync-md/src/background/sync/sp-to-md.ts b/packages/plugin-dev/sync-md/src/background/sync/sp-to-md.ts new file mode 100644 index 0000000000..ff801f66f6 --- /dev/null +++ b/packages/plugin-dev/sync-md/src/background/sync/sp-to-md.ts @@ -0,0 +1,151 @@ +import { Task } from '@super-productivity/plugin-api'; +import { writeTasksFile, ensureDirectoryExists } from '../helper/file-utils'; +import { LocalUserCfg } from '../local-config'; + +/** + * Replicate Super Productivity tasks to markdown file + * Gets tasks for the specific project and writes them to the configured file + */ +export const spToMd = async (config: LocalUserCfg): Promise => { + // Ensure directory exists + await ensureDirectoryExists(config.filePath); + + // Get tasks and project info + const allTasks = await PluginAPI.getTasks(); + const projects = await PluginAPI.getAllProjects(); + const project = projects.find((p) => p.id === config.projectId); + + // Filter tasks for this project + const projectTasks = allTasks.filter((task) => task.projectId === config.projectId); + + // If project has taskIds, use them to order the tasks + let orderedTasks = projectTasks; + if (project && project.taskIds && project.taskIds.length > 0) { + // Create a map for quick lookup + const taskMap = new Map(); + projectTasks.forEach((task) => { + taskMap.set(task.id, task); + }); + + // Order tasks according to project.taskIds + const orderedParentTasks: Task[] = []; + project.taskIds.forEach((taskId) => { + const task = taskMap.get(taskId); + if (task && !task.parentId) { + orderedParentTasks.push(task); + } + }); + + // Replace parent tasks with ordered ones, keep subtasks + const subtasks = projectTasks.filter((task) => task.parentId); + orderedTasks = [...orderedParentTasks, ...subtasks]; + } + + // Convert project tasks to markdown + const markdown = convertTasksToMarkdown(orderedTasks); + + // Write to file + await writeTasksFile(config.filePath, markdown); +}; + +/** + * Convert SP tasks to markdown format with proper hierarchy + * Implements the user requirements: + * - Tasks and subtasks as markdown checklists + * - Task notes shown below subtasks + * - Proper indentation for subtasks + */ +export const convertTasksToMarkdown = (tasks: Task[]): string => { + const lines: string[] = []; + + // Separate parent tasks and subtasks + const parentTasks = tasks.filter((task) => !task.parentId); + const subtasksByParent = new Map(); + + // Group subtasks by parent + tasks + .filter((task) => task.parentId) + .forEach((subtask) => { + if (!subtasksByParent.has(subtask.parentId!)) { + subtasksByParent.set(subtask.parentId!, []); + } + subtasksByParent.get(subtask.parentId!)!.push(subtask); + }); + + // Process parent tasks and their subtasks + for (const parentTask of parentTasks) { + // Add parent task + lines.push(formatTask(parentTask)); + + // Add subtasks in the order specified by subTaskIds + if (parentTask.subTaskIds && parentTask.subTaskIds.length > 0) { + // Create a map for quick lookup + const subtasksMap = new Map(); + const subtasks = subtasksByParent.get(parentTask.id!) || []; + subtasks.forEach((subtask) => { + subtasksMap.set(subtask.id!, subtask); + }); + + // Process subtasks in the order specified by subTaskIds + for (const subTaskId of parentTask.subTaskIds) { + const subtask = subtasksMap.get(subTaskId); + if (subtask) { + lines.push(formatTask(subtask, 2)); // 2 spaces indent + + // Add subtask notes if present + if (subtask.notes && subtask.notes.trim()) { + const noteLines = subtask.notes.split('\n'); + for (const noteLine of noteLines) { + if (noteLine.trim()) { + lines.push(` ${noteLine}`); // 4 spaces indent for notes + } + } + } + } + } + } else { + // Fallback: if no subTaskIds, use the old approach + const subtasks = subtasksByParent.get(parentTask.id!) || []; + for (const subtask of subtasks) { + lines.push(formatTask(subtask, 2)); // 2 spaces indent + + // Add subtask notes if present + if (subtask.notes && subtask.notes.trim()) { + const noteLines = subtask.notes.split('\n'); + for (const noteLine of noteLines) { + if (noteLine.trim()) { + lines.push(` ${noteLine}`); // 4 spaces indent for notes + } + } + } + } + } + + // Add parent task notes after subtasks + if (parentTask.notes && parentTask.notes.trim()) { + const noteLines = parentTask.notes.split('\n'); + for (const noteLine of noteLines) { + if (noteLine.trim()) { + lines.push(` ${noteLine}`); // 2 spaces indent for parent notes + } + } + } + + // Add empty line after each parent task group + lines.push(''); + } + + return lines.join('\n').trim(); +}; + +/** + * Format a single task as a markdown checkbox + */ +export const formatTask = (task: Task, indent: number = 0): string => { + const indentStr = ' '.repeat(indent); + const checkbox = task.isDone ? '[x]' : '[ ]'; + const title = task.title; + const id = task.id ? ` ` : ''; + + return `${indentStr}- ${checkbox} ${id}${title}`; +}; diff --git a/packages/plugin-dev/sync-md/src/background/sync/sync-manager.ts b/packages/plugin-dev/sync-md/src/background/sync/sync-manager.ts new file mode 100644 index 0000000000..b92bf65278 --- /dev/null +++ b/packages/plugin-dev/sync-md/src/background/sync/sync-manager.ts @@ -0,0 +1,177 @@ +import { startFileWatcher, stopFileWatcher } from './file-watcher'; +import { spToMd } from './sp-to-md'; +import { mdToSp } from './md-to-sp'; +import { getFileStats, readTasksFile } from '../helper/file-utils'; +import { SYNC_DEBOUNCE_MS, SYNC_DEBOUNCE_MS_UNFOCUSED } from '../config.const'; +import { PluginHooks } from '@super-productivity/plugin-api'; +import { LocalUserCfg } from '../local-config'; +import { logSyncVerification, verifySyncState } from './verify-sync'; + +let syncInProgress = false; +let lastSyncTime: Date | null = null; +let debounceTimer: NodeJS.Timeout | null = null; +let isWindowFocused = true; + +export const initSyncManager = (config: LocalUserCfg): void => { + // Stop any existing file watcher + stopFileWatcher(); + + // Set up window focus tracking + setupWindowFocusTracking(); + + // Perform initial sync + performInitialSync(config).then((r) => console.log('SyncMD initial sync', r)); + + // Set up file watcher for ongoing sync + startFileWatcher(config.filePath, () => { + handleFileChange(config); + }); + + // Set up hooks for SP changes + setupSpHooks(config); +}; + +const performInitialSync = async (config: LocalUserCfg): Promise => { + if (syncInProgress) return; + syncInProgress = true; + + try { + const fileStats = await getFileStats(config.filePath); + const lastSpChange = getLastSpChangeTime(); + + // Determine sync direction + if (!fileStats) { + // No file exists, create from SP + await spToMd(config); + } else if (!lastSpChange || fileStats.mtime > lastSpChange) { + // File is newer, sync to SP + const content = await readTasksFile(config.filePath); + if (content) { + // Use the project ID from config, fallback to default + const projectId = config.projectId; + await mdToSp(content, projectId); + } + } else { + // SP is newer, sync to file + await spToMd(config); + } + + lastSyncTime = new Date(); + + // Verify sync state after initial sync + const verificationResult = await verifySyncState(config); + logSyncVerification(verificationResult, 'initial sync'); + } finally { + syncInProgress = false; + } +}; + +const handleFileChange = (config: LocalUserCfg): void => { + if (debounceTimer) { + clearTimeout(debounceTimer); + } + + // Use longer debounce when window is not focused to avoid conflicts while editing + const debounceMs = isWindowFocused ? SYNC_DEBOUNCE_MS : SYNC_DEBOUNCE_MS_UNFOCUSED; + console.log( + `[sync-md] File change detected, debouncing for ${debounceMs}ms (window ${isWindowFocused ? 'focused' : 'unfocused'})`, + ); + + debounceTimer = setTimeout(async () => { + if (syncInProgress) return; + syncInProgress = true; + + try { + const content = await readTasksFile(config.filePath); + if (content) { + // Use the project ID from config, fallback to default + const projectId = config.projectId; + await mdToSp(content, projectId); + lastSyncTime = new Date(); + + // Verify sync state after file change sync + const verificationResult = await verifySyncState(config); + logSyncVerification(verificationResult, 'MD to SP sync (file change)'); + + // If there are still differences after MD→SP sync, trigger SP→MD sync to resolve them + if (!verificationResult.isInSync) { + console.log( + '[sync-md] MD to SP sync incomplete, triggering SP to MD sync to resolve remaining differences', + ); + + // Temporarily disable file watcher to prevent triggering another MD→SP sync + stopFileWatcher(); + + try { + await spToMd(config); + + // Verify again after SP→MD sync + const finalVerification = await verifySyncState(config); + logSyncVerification( + finalVerification, + 'SP to MD sync (resolving differences)', + ); + } finally { + // Re-enable file watcher + startFileWatcher(config.filePath, () => { + handleFileChange(config); + }); + } + } + } + } finally { + syncInProgress = false; + } + }, SYNC_DEBOUNCE_MS); +}; + +const setupSpHooks = (config: LocalUserCfg): void => { + // Listen for task changes + PluginAPI.registerHook(PluginHooks.ANY_TASK_UPDATE, () => { + handleSpChange(config); + }); + PluginAPI.registerHook(PluginHooks.PROJECT_LIST_UPDATE, () => { + handleSpChange(config); + }); +}; + +const handleSpChange = (config: LocalUserCfg): void => { + if (debounceTimer) { + clearTimeout(debounceTimer); + } + + // For SP changes, we always use the short debounce since the user is actively using SP + debounceTimer = setTimeout(async () => { + if (syncInProgress) return; + syncInProgress = true; + + try { + await spToMd(config); + lastSyncTime = new Date(); + + // Verify sync state after SP change sync + const verificationResult = await verifySyncState(config); + logSyncVerification(verificationResult, 'SP to MD sync (SP change)'); + } finally { + syncInProgress = false; + } + }, SYNC_DEBOUNCE_MS); +}; + +const getLastSpChangeTime = (): Date | null => { + // This is a simplified version - in reality, you'd track the actual last change + return lastSyncTime; +}; + +const setupWindowFocusTracking = (): void => { + // Check if the API is available + if (!PluginAPI.onWindowFocusChange) { + console.log('[sync-md] Window focus tracking not available'); + return; + } + + PluginAPI.onWindowFocusChange((isFocused: boolean) => { + isWindowFocused = isFocused; + console.log(`[sync-md] Window focus changed: ${isFocused ? 'focused' : 'unfocused'}`); + }); +}; diff --git a/packages/plugin-dev/sync-md/src/background/sync/verify-sync.ts b/packages/plugin-dev/sync-md/src/background/sync/verify-sync.ts new file mode 100644 index 0000000000..9a841bfc36 --- /dev/null +++ b/packages/plugin-dev/sync-md/src/background/sync/verify-sync.ts @@ -0,0 +1,236 @@ +import { Task } from '@super-productivity/plugin-api'; +import { readTasksFile } from '../helper/file-utils'; +import { parseMarkdown } from './markdown-parser'; +import { LocalUserCfg } from '../local-config'; + +export interface SyncVerificationResult { + isInSync: boolean; + differences: SyncDifference[]; +} + +export interface SyncDifference { + type: 'missing-in-md' | 'missing-in-sp' | 'order-mismatch' | 'property-mismatch'; + taskId?: string; + message: string; + details?: any; +} + +/** + * Verifies that the state between Super Productivity and markdown file is in sync + * Returns true if in sync, false otherwise with details about differences + */ +export const verifySyncState = async ( + config: LocalUserCfg, +): Promise => { + const differences: SyncDifference[] = []; + + try { + // Get tasks from SP + const allTasks = await PluginAPI.getTasks(); + const projects = await PluginAPI.getAllProjects(); + const project = projects.find((p) => p.id === config.projectId); + const spTasks = allTasks.filter((task) => task.projectId === config.projectId); + + // Get tasks from markdown + const mdContent = await readTasksFile(config.filePath); + if (!mdContent) { + if (spTasks.length > 0) { + differences.push({ + type: 'missing-in-md', + message: `Markdown file is empty but SP has ${spTasks.length} tasks`, + }); + } + return { isInSync: differences.length === 0, differences }; + } + + const mdTasks = parseMarkdown(mdContent); + + // Create maps for quick lookup + const spTaskMap = new Map(); + const mdTaskMap = new Map(); + + spTasks.forEach((task) => { + if (task.id) { + spTaskMap.set(task.id, task); + } + }); + + mdTasks.forEach((task) => { + if (task.id) { + mdTaskMap.set(task.id, task); + } + }); + + // Check for tasks missing in markdown + for (const [taskId, spTask] of spTaskMap) { + if (!mdTaskMap.has(taskId)) { + differences.push({ + type: 'missing-in-md', + taskId, + message: `Task "${spTask.title}" exists in SP but not in markdown`, + }); + } + } + + // Check for tasks missing in SP + for (const [taskId, mdTask] of mdTaskMap) { + if (!spTaskMap.has(taskId)) { + differences.push({ + type: 'missing-in-sp', + taskId, + message: `Task "${mdTask.title}" exists in markdown but not in SP`, + }); + } + } + + // Check property matches for tasks that exist in both + for (const [taskId, mdTask] of mdTaskMap) { + const spTask = spTaskMap.get(taskId); + if (spTask) { + // Check title + if (spTask.title !== mdTask.title) { + differences.push({ + type: 'property-mismatch', + taskId, + message: `Title mismatch for task ${taskId}`, + details: { sp: spTask.title, md: mdTask.title }, + }); + } + + // Check completion status + if (spTask.isDone !== mdTask.completed) { + differences.push({ + type: 'property-mismatch', + taskId, + message: `Completion status mismatch for task "${spTask.title}"`, + details: { sp: spTask.isDone, md: mdTask.completed }, + }); + } + + // Check parent relationship (treat null and undefined as equivalent) + const spParentId = spTask.parentId || null; + const mdParentId = mdTask.parentId || null; + if (spParentId !== mdParentId) { + differences.push({ + type: 'property-mismatch', + taskId, + message: `Parent ID mismatch for task "${spTask.title}"`, + details: { sp: spTask.parentId, md: mdTask.parentId }, + }); + } + + // Check notes + const spNotes = spTask.notes?.trim() || ''; + const mdNotes = mdTask.notes?.trim() || ''; + if (spNotes !== mdNotes) { + differences.push({ + type: 'property-mismatch', + taskId, + message: `Notes mismatch for task "${spTask.title}"`, + details: { sp: spNotes, md: mdNotes }, + }); + } + } + } + + // Check task order + const spParentTasks = spTasks.filter((t) => !t.parentId); + const mdParentTasks = mdTasks.filter((t) => !t.parentId); + + // If project has taskIds, use them for ordering + if (project && project.taskIds && project.taskIds.length > 0) { + const orderedSpTaskIds = project.taskIds.filter((id) => { + const task = spTaskMap.get(id); + return task && !task.parentId; + }); + + const mdParentTaskIds = mdParentTasks.map((t) => t.id).filter((id) => id); + + const parentOrderChanged = + orderedSpTaskIds.length !== mdParentTaskIds.length || + orderedSpTaskIds.some((id, index) => id !== mdParentTaskIds[index]); + + if (parentOrderChanged) { + differences.push({ + type: 'order-mismatch', + message: 'Parent task order mismatch', + details: { sp: orderedSpTaskIds, md: mdParentTaskIds }, + }); + } + } + + // Check subtask order + for (const [taskId, spTask] of spTaskMap) { + if (spTask.subTaskIds && spTask.subTaskIds.length > 0) { + const mdTask = mdTaskMap.get(taskId); + if (mdTask) { + // Get actual subtask IDs from markdown in the order they appear + // Sort by line number to maintain the order from the file + const mdSubtaskIds = mdTasks + .filter((t) => t.parentId === taskId) + .sort((a, b) => a.line - b.line) + .map((t) => t.id) + .filter((id) => id); + + const spSubtaskIds = spTask.subTaskIds.filter((id) => spTaskMap.has(id)); + + const subtaskOrderChanged = + spSubtaskIds.length !== mdSubtaskIds.length || + spSubtaskIds.some((id, index) => id !== mdSubtaskIds[index]); + + if (subtaskOrderChanged) { + differences.push({ + type: 'order-mismatch', + taskId, + message: `Subtask order mismatch for task "${spTask.title}"`, + details: { sp: spSubtaskIds, md: mdSubtaskIds }, + }); + } + } + } + } + + return { + isInSync: differences.length === 0, + differences, + }; + } catch (error) { + console.error('Error verifying sync state:', error); + return { + isInSync: false, + differences: [ + { + type: 'property-mismatch', + message: `Error during verification: ${error}`, + }, + ], + }; + } +}; + +/** + * Logs the sync verification result + */ +export const logSyncVerification = ( + result: SyncVerificationResult, + context: string, +): void => { + if (result.isInSync) { + console.log(`✅ Sync verification passed: ${context}`); + } else { + console.log(`❌ Sync verification failed: ${context}`); + console.log(`Found ${result.differences.length} differences:`); + result.differences.forEach((diff) => { + console.log(` - ${diff.type}: ${diff.message}`); + if (diff.details) { + // For order mismatches, show the actual arrays for better debugging + if (diff.type === 'order-mismatch' && diff.details.sp && diff.details.md) { + console.log(` SP order: [${diff.details.sp.join(', ')}]`); + console.log(` MD order: [${diff.details.md.join(', ')}]`); + } else { + console.log(` Details:`, diff.details); + } + } + }); + } +}; diff --git a/packages/plugin-dev/sync-md/src/background/ui-bridge.ts b/packages/plugin-dev/sync-md/src/background/ui-bridge.ts new file mode 100644 index 0000000000..2d50753502 --- /dev/null +++ b/packages/plugin-dev/sync-md/src/background/ui-bridge.ts @@ -0,0 +1,102 @@ +import { loadLocalConfig, LocalUserCfg, saveLocalConfig } from './local-config'; +import { initSyncManager } from './sync/sync-manager'; + +interface UiBridgeConfig { + getConfig: () => LocalUserCfg | null; + saveConfig: (config: LocalUserCfg) => void; + triggerSync: () => void; +} + +interface PluginMessage { + type: string; + config?: LocalUserCfg; +} + +const bridgeConfig: UiBridgeConfig = { + getConfig: () => loadLocalConfig(), + saveConfig: (newConfig: LocalUserCfg) => { + console.log('[sync-md] Saving config:', newConfig); + saveLocalConfig(newConfig); + console.log('[sync-md] Config saved to localStorage'); + // Re-initialize sync when config changes + if (newConfig.filePath) { + console.log( + '[sync-md] Initializing sync manager with config, filePath:', + newConfig.filePath, + ); + try { + initSyncManager(newConfig); + console.log('[sync-md] Sync manager initialized successfully'); + } catch (error) { + console.error('[sync-md] Failed to initialize sync manager:', error); + throw error; + } + } + }, + triggerSync: () => { + const config = loadLocalConfig(); + if (config?.filePath) { + // Transform config to match sync-manager expectations + initSyncManager(config); + } + }, +}; + +export const initUiBridge = (): void => { + console.log( + '[sync-md] initUiBridge called, PluginAPI.onMessage available:', + !!PluginAPI.onMessage, + ); + + // Handle messages from UI + if (PluginAPI.onMessage) { + console.log('[sync-md] Registering message handler'); + PluginAPI.onMessage(async (message: PluginMessage) => { + console.log('[sync-md] Received message:', message); + + switch (message.type) { + case 'getProjects': + try { + const projects = await PluginAPI.getAllProjects(); + return { success: true, projects }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + + case 'getConfig': + try { + const config = bridgeConfig.getConfig(); + return { success: true, config }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + + case 'saveConfig': + try { + if (message.config) { + bridgeConfig.saveConfig(message.config); + return { success: true }; + } + return { success: false, error: 'No config provided' }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + + case 'syncNow': + try { + const config = bridgeConfig.getConfig(); + if (config?.filePath) { + bridgeConfig.triggerSync(); + return { success: true }; + } + return { success: false, error: 'Sync not enabled' }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + + default: + return { success: false, error: 'Unknown message type' }; + } + }); + } +}; diff --git a/packages/plugin-dev/sync-md/src/index.ts b/packages/plugin-dev/sync-md/src/index.ts index 50d19486ab..e3c3d4e808 100644 --- a/packages/plugin-dev/sync-md/src/index.ts +++ b/packages/plugin-dev/sync-md/src/index.ts @@ -1,11 +1,9 @@ // Sync-MD Plugin // Main entry point for the plugin -export * from './background'; +export * from './background/background'; +export * from './background/simple-file-watcher'; +export * from './background/sync-coordinator'; +export * from './background/sync-utils'; +export * from './background/markdown-parser'; export * from './shared/types'; - -// Plugin metadata -export const PLUGIN_NAME = 'sync-md'; -export const PLUGIN_VERSION = '2.0.0'; -export const PLUGIN_DESCRIPTION = - 'Bidirectional synchronization between markdown files and SuperProductivity tasks'; diff --git a/packages/plugin-dev/sync-md/src/shared/types.ts b/packages/plugin-dev/sync-md/src/shared/types.ts index 786ab11222..58f8050a16 100644 --- a/packages/plugin-dev/sync-md/src/shared/types.ts +++ b/packages/plugin-dev/sync-md/src/shared/types.ts @@ -1,9 +1,6 @@ -export type SyncDirection = 'bidirectional' | 'fileToProject' | 'projectToFile'; - export interface SyncConfig { filePath: string; projectId: string; - syncDirection: SyncDirection; enabled: boolean; } @@ -28,26 +25,3 @@ export interface Task { notes?: string; subTaskIds?: string[]; } - -export interface MarkdownTask { - title: string; - isDone: boolean; - lineNumber: number; - indentLevel: number; - subTasks: MarkdownTask[]; -} - -export interface SyncResult { - tasksAdded: number; - tasksUpdated: number; - tasksDeleted: number; - conflicts: SyncConflict[]; -} - -export interface SyncConflict { - taskId: string; - taskTitle: string; - fileValue: any; - projectValue: any; - resolution?: 'file' | 'project' | 'skip'; -} diff --git a/packages/plugin-dev/sync-md/src/types.d.ts b/packages/plugin-dev/sync-md/src/types.d.ts new file mode 100644 index 0000000000..2025fff871 --- /dev/null +++ b/packages/plugin-dev/sync-md/src/types.d.ts @@ -0,0 +1,7 @@ +import { PluginAPI } from '@super-productivity/plugin-api'; + +declare global { + const PluginAPI: PluginAPI; +} + +export {}; diff --git a/packages/plugin-dev/sync-md/src/ui/index.html b/packages/plugin-dev/sync-md/src/ui/index.html index 94fcf58221..0ac0bb64f9 100644 --- a/packages/plugin-dev/sync-md/src/ui/index.html +++ b/packages/plugin-dev/sync-md/src/ui/index.html @@ -9,6 +9,52 @@ Sync.md Configuration @@ -346,30 +277,13 @@
Select the project to sync tasks with
-
- - -
Control how changes are synchronized
-
-
-
@@ -378,44 +292,12 @@ id="status" class="status" > - -
-

Sync Status

-
- Status: - Not configured -
-
- Last sync: - Never -
-
- File watching: - Inactive -
-