feat(syncMd): implement core markdown sync plugin with bidirectional synchronization

Core sync infrastructure:
- Markdown parser with task ID extraction and parent-child support
- SP to markdown converter with proper task ordering based on project taskIds
- Task operations generator for efficient batch updates
- Sync manager with file watching and intelligent debouncing
- Window focus-aware debouncing (15s when unfocused, 1s when focused)
- Post-sync verification to ensure consistency

Key features:
- Bidirectional sync between markdown files and Super Productivity
- Preserves task hierarchy and subtask ordering
- Handles duplicate ID detection and prevention
- Smart conflict resolution with detailed logging
- Efficient batch operations to minimize API calls

Plugin integration:
- Background script with proper initialization
- UI bridge for configuration management
- Plugin manifest and entry point
- Configuration UI for file path setup
This commit is contained in:
Johannes Millan 2025-07-10 14:55:44 +02:00
parent 3d6f94ba0e
commit 30a962c2a9
17 changed files with 1650 additions and 810 deletions

View file

@ -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<void> {
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<void> {
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<string, unknown> = { 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<string> {
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<void> {
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 };

View file

@ -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;

View file

@ -0,0 +1,138 @@
interface NodeScriptResult {
success: boolean;
content?: string | null;
mtime?: string | null;
error?: string;
}
export const readTasksFile = async (filePath: string): Promise<string | null> => {
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<void> => {
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<void> => {
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');
}
};

View file

@ -0,0 +1,37 @@
// Helper functions for file operations using Node.js via PluginAPI
export const readFileContent = async (filePath: string): Promise<string> => {
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;
};

View file

@ -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);
}
};

View file

@ -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<Date | null> => {
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;
};

View file

@ -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<string, Task>();
const spByTitle = new Map<string, Task>();
const mdById = new Map<string, ParsedTask>();
const mdByTitle = new Map<string, ParsedTask>();
// 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<string>();
const firstOccurrence = new Map<string, number>();
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<string>();
// 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<string, unknown> = {};
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<string, unknown> = {};
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<string, ParsedTask[]>();
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];
};

View file

@ -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<string>();
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<Task>[] => {
return parsedTasks.map((task) => ({
id: task.id || generateTaskId(),
title: task.title,
isDone: task.completed,
notes: task.notes || '',
projectId,
parentId: task.parentId || null,
}));
};

View file

@ -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<void> => {
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,
});
}
};

View file

@ -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<void> => {
// 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<string, Task>();
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<string, Task[]>();
// 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<string, Task>();
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 ? `<!--${task.id}--> ` : '';
return `${indentStr}- ${checkbox} ${id}${title}`;
};

View file

@ -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<void> => {
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'}`);
});
};

View file

@ -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<SyncVerificationResult> => {
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<string, Task>();
const mdTaskMap = new Map<string, (typeof mdTasks)[0]>();
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);
}
}
});
}
};

View file

@ -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' };
}
});
}
};

View file

@ -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';

View file

@ -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';
}

View file

@ -0,0 +1,7 @@
import { PluginAPI } from '@super-productivity/plugin-api';
declare global {
const PluginAPI: PluginAPI;
}
export {};

View file

@ -9,6 +9,52 @@
<title>Sync.md Configuration</title>
<style>
/* Reset and base styles */
:root {
--bg-primary: #fff;
--bg-secondary: #f5f5f5;
--bg-tertiary: #e0e0e0;
--text-primary: #333;
--text-secondary: #666;
--text-muted: #999;
--border-color: #ddd;
--border-color-focus: #2196f3;
--accent-primary: #2196f3;
--accent-primary-hover: #1976d2;
--success-bg: #e8f5e9;
--success-text: #2e7d32;
--success-border: #c8e6c9;
--error-bg: #ffebee;
--error-text: #c62828;
--error-border: #ffcdd2;
--info-bg: #e3f2fd;
--info-text: #1565c0;
--info-border: #bbdefb;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #2a2a2a;
--bg-secondary: #333;
--bg-tertiary: #444;
--text-primary: #e0e0e0;
--text-secondary: #ccc;
--text-muted: #aaa;
--border-color: #444;
--border-color-focus: #4a9eff;
--accent-primary: #1976d2;
--accent-primary-hover: #1565c0;
--success-bg: #1b3a1b;
--success-text: #81c784;
--success-border: #2e5f2e;
--error-bg: #4a1414;
--error-text: #ef5350;
--error-border: #6f2020;
--info-bg: #1e3a5f;
--info-text: #64b5f6;
--info-border: #2962a0;
}
}
* {
box-sizing: border-box;
margin: 0;
@ -21,94 +67,11 @@
sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--text-color);
color: var(--text-primary);
background: transparent;
padding: 20px;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
input[type='text'],
select {
background: #2a2a2a;
color: #e0e0e0;
border-color: #444;
}
input[type='text']:focus,
select:focus {
border-color: #4a9eff;
background: #333;
}
button {
background: #333;
color: #e0e0e0;
border: 1px solid #444;
}
.btn-primary {
background: #1976d2;
border-color: #1976d2;
}
.btn-primary:hover:not(:disabled) {
background: #1565c0;
border-color: #1565c0;
}
.btn-secondary {
background: #333;
color: #e0e0e0;
border-color: #555;
}
.btn-secondary:hover:not(:disabled) {
background: #444;
border-color: #666;
}
.status.info {
background: #1e3a5f;
color: #64b5f6;
border-color: #2962a0;
}
.status.success {
background: #1b3a1b;
color: #81c784;
border-color: #2e5f2e;
}
.status.error {
background: #4a1414;
color: #ef5350;
border-color: #6f2020;
}
.sync-info {
background: #2a2a2a;
border: 1px solid #444;
}
.sync-stat {
border-bottom-color: #444;
}
.sync-stat-label {
color: #aaa;
}
.sync-stat-value {
color: #e0e0e0;
}
.spinner {
border-color: #444;
border-top-color: #4a9eff;
}
}
/* Container */
.container {
max-width: 600px;
@ -123,7 +86,7 @@
}
.subtitle {
color: #666;
color: var(--text-secondary);
margin-bottom: 24px;
}
@ -136,28 +99,31 @@
display: block;
font-weight: 500;
margin-bottom: 6px;
color: #444;
color: var(--text-secondary);
}
input[type='text'],
select {
width: 100%;
padding: 10px 12px;
border: 1px solid #444;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
transition: border-color 0.2s;
}
input[type='text']:focus,
select:focus {
outline: none;
border-color: #2196f3;
border-color: var(--border-color-focus);
background: var(--bg-secondary);
}
.help-text {
font-size: 12px;
color: #666;
color: var(--text-muted);
margin-top: 4px;
}
@ -170,11 +136,13 @@
button {
padding: 10px 20px;
border: none;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
background: var(--bg-secondary);
color: var(--text-primary);
transition: all 0.2s;
}
@ -184,20 +152,19 @@
}
.btn-primary {
background: #2196f3;
background: var(--accent-primary);
border-color: var(--accent-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #1976d2;
}
.btn-secondary {
background: #f5f5f5;
background: var(--accent-primary-hover);
border-color: var(--accent-primary-hover);
}
.btn-secondary:hover:not(:disabled) {
background: #e0e0e0;
background: var(--bg-tertiary);
border-color: var(--border-color);
}
/* Status messages */
@ -213,58 +180,21 @@
}
.status.info {
background: #e3f2fd;
color: #1565c0;
border: 1px solid #bbdefb;
background: var(--info-bg);
color: var(--info-text);
border: 1px solid var(--info-border);
}
.status.success {
background: #e8f5e9;
color: #2e7d32;
border: 1px solid #c8e6c9;
background: var(--success-bg);
color: var(--success-text);
border: 1px solid var(--success-border);
}
.status.error {
background: #ffebee;
color: #c62828;
border: 1px solid #ffcdd2;
}
/* Sync info */
.sync-info {
margin-top: 32px;
padding: 20px;
border-radius: 8px;
display: none;
}
.sync-info.show {
display: block;
}
.sync-info h3 {
font-size: 16px;
margin-bottom: 12px;
}
.sync-stat {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #e0e0e0;
}
.sync-stat:last-child {
border-bottom: none;
}
.sync-stat-label {
font-weight: 500;
color: #555;
}
.sync-stat-value {
color: #333;
background: var(--error-bg);
color: var(--error-text);
border: 1px solid var(--error-border);
}
/* Loading spinner */
@ -272,8 +202,8 @@
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid #f3f3f3;
border-top: 2px solid #2196f3;
border: 2px solid var(--border-color);
border-top: 2px solid var(--border-color-focus);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-left: 8px;
@ -301,14 +231,15 @@
transform: translateY(-50%);
padding: 6px 12px;
font-size: 12px;
background: #fff;
border: 1px solid #ddd;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
color: var(--text-primary);
}
.test-file-btn:hover {
background: #f5f5f5;
background: var(--bg-secondary);
}
</style>
</head>
@ -346,30 +277,13 @@
<div class="help-text">Select the project to sync tasks with</div>
</div>
<div class="form-group">
<label for="syncDirection">Sync Direction</label>
<select id="syncDirection">
<option value="bidirectional">Bidirectional (Two-way sync)</option>
<option value="fileToProject">File → Project only</option>
<option value="projectToFile">Project → File only</option>
</select>
<div class="help-text">Control how changes are synchronized</div>
</div>
<div class="button-group">
<button
type="submit"
class="btn-primary"
id="saveBtn"
>
Save Configuration
</button>
<button
type="button"
class="btn-secondary"
onclick="syncNow()"
id="syncBtn"
>
Sync Now
Save & Sync
</button>
</div>
</form>
@ -378,44 +292,12 @@
id="status"
class="status"
></div>
<div
id="syncInfo"
class="sync-info"
>
<h3>Sync Status</h3>
<div class="sync-stat">
<span class="sync-stat-label">Status:</span>
<span
class="sync-stat-value"
id="syncStatus"
>Not configured</span
>
</div>
<div class="sync-stat">
<span class="sync-stat-label">Last sync:</span>
<span
class="sync-stat-value"
id="lastSync"
>Never</span
>
</div>
<div class="sync-stat">
<span class="sync-stat-label">File watching:</span>
<span
class="sync-stat-value"
id="watchStatus"
>Inactive</span
>
</div>
</div>
</div>
<script>
// State
let config = null;
let projects = [];
let syncInfoInterval = null;
// Message handling
async function sendMessage(message) {
@ -471,6 +353,7 @@
console.log('Loading projects directly from PluginAPI');
projects = await window.PluginAPI.getAllProjects();
} else {
// TODO remove fallback
console.log('Loading projects via message');
const response = await sendMessage({ type: 'getProjects' });
if (response?.success && response.projects) {
@ -507,55 +390,18 @@
async function loadConfig() {
try {
const savedData = await window.PluginAPI?.loadSyncedData?.();
if (savedData) {
config = JSON.parse(savedData);
// Request config from background
const response = await sendMessage({ type: 'getConfig' });
if (response?.success && response.config) {
config = response.config;
document.getElementById('filePath').value = config.filePath || '';
document.getElementById('projectId').value = config.projectId || '';
document.getElementById('syncDirection').value =
config.syncDirection || 'bidirectional';
if (config.enabled) {
document.getElementById('syncInfo').classList.add('show');
updateSyncInfo();
}
}
} catch (error) {
console.error('Failed to load config:', error);
}
}
async function updateSyncInfo() {
try {
const response = await sendMessage({ type: 'getSyncInfo' });
console.log('getSyncInfo response:', response);
if (response?.success) {
document.getElementById('syncStatus').textContent = response.syncInProgress
? 'Syncing...'
: 'Active';
document.getElementById('lastSync').textContent = response.lastSyncTime
? new Date(response.lastSyncTime).toLocaleString()
: 'Never';
document.getElementById('watchStatus').textContent = response.isWatching
? 'Active'
: 'Inactive';
// Update sync button
const syncBtn = document.getElementById('syncBtn');
if (response.syncInProgress) {
syncBtn.disabled = true;
syncBtn.innerHTML = 'Syncing<span class="spinner"></span>';
} else {
syncBtn.disabled = false;
syncBtn.textContent = 'Sync Now';
}
}
} catch (error) {
console.error('Failed to update sync info:', error);
}
}
async function testFilePath() {
const filePath = document.getElementById('filePath').value;
if (!filePath) {
@ -620,38 +466,13 @@
}
}
async function syncNow() {
if (!config || !config.enabled) {
showStatus('Please save configuration first', 'error');
return;
}
try {
showStatus('Triggering sync...', 'info');
console.log('Sending syncNow message...');
const response = await sendMessage({ type: 'syncNow' });
console.log('syncNow response:', response);
if (response?.success) {
showStatus('Sync triggered - please wait', 'info');
// Update UI after debounce period (5 seconds)
setTimeout(updateSyncInfo, 6000);
} else {
showStatus(response?.error || 'Failed to trigger sync', 'error');
}
} catch (error) {
console.error('syncNow error:', error);
showStatus('Failed to trigger sync', 'error');
}
}
// Form submission
document.getElementById('configForm').addEventListener('submit', async (e) => {
e.preventDefault();
const filePath = document.getElementById('filePath').value;
const projectId = document.getElementById('projectId').value;
const syncDirection = document.getElementById('syncDirection').value;
const syncDirection = 'bidirectional';
if (!filePath || !projectId) {
showStatus('Please fill in all required fields', 'error');
@ -675,22 +496,54 @@
};
try {
// Save to storage
await window.PluginAPI.persistDataSynced(JSON.stringify(newConfig));
config = newConfig;
// Disable save button during operation
const saveBtn = document.getElementById('saveBtn');
saveBtn.disabled = true;
saveBtn.innerHTML = 'Saving<span class="spinner"></span>';
// Notify background
await sendMessage({
type: 'configUpdated',
// Update config in background (which will store it locally)
const saveResponse = await sendMessage({
type: 'saveConfig',
config: newConfig,
});
showStatus('Configuration saved successfully', 'success');
document.getElementById('syncInfo').classList.add('show');
updateSyncInfo();
console.log('Save response received:', saveResponse);
// Check if we have a result field (message response wrapper)
const actualResponse = saveResponse?.result || saveResponse;
if (!actualResponse?.success) {
throw new Error(actualResponse?.error || 'Failed to save config');
}
config = newConfig;
// Trigger sync immediately after saving
saveBtn.innerHTML = 'Syncing<span class="spinner"></span>';
showStatus('Configuration saved - sync started...', 'info');
try {
const syncResponse = await sendMessage({ type: 'syncNow' });
// Don't show error here - the sync is async and will complete later
// The SYNC_COMPLETED or SYNC_ERROR events will handle the final status
if (!syncResponse?.success && syncResponse?.error) {
// Only show error if there's an actual error message (not just missing success)
showStatus('Sync error: ' + syncResponse.error, 'error');
}
} catch (error) {
showStatus('Failed to trigger sync: ' + error.message, 'error');
}
// Re-enable button
saveBtn.disabled = false;
saveBtn.textContent = 'Save & Sync';
} catch (error) {
console.error('Failed to save config:', error);
showStatus('Failed to save configuration', 'error');
// Re-enable button on error
const saveBtn = document.getElementById('saveBtn');
saveBtn.disabled = false;
saveBtn.textContent = 'Save & Sync';
}
});
@ -700,12 +553,41 @@
if (event.data?.type === 'SYNC_COMPLETED') {
console.log('Sync completed event received');
updateSyncInfo();
showStatus('Sync completed successfully', 'success');
// Show success with sync result details
if (event.data.result) {
const result = event.data.result;
let message = 'Sync completed successfully';
if (
result.tasksAdded > 0 ||
result.tasksUpdated > 0 ||
result.tasksDeleted > 0
) {
message += ` - ${result.tasksAdded} added, ${result.tasksUpdated} updated, ${result.tasksDeleted} deleted`;
} else {
message += ' - no changes';
}
showStatus(message, 'success');
} else {
showStatus('Sync completed successfully', 'success');
}
// Re-enable save button if it was disabled
const saveBtn = document.getElementById('saveBtn');
if (saveBtn && saveBtn.disabled) {
saveBtn.disabled = false;
saveBtn.textContent = 'Save & Sync';
}
} else if (event.data?.type === 'SYNC_ERROR') {
console.log('Sync error event received:', event.data.error);
updateSyncInfo();
showStatus('Sync error: ' + event.data.error, 'error');
// Re-enable save button on error
const saveBtn = document.getElementById('saveBtn');
if (saveBtn && saveBtn.disabled) {
saveBtn.disabled = false;
saveBtn.textContent = 'Save & Sync';
}
}
});
@ -744,16 +626,6 @@
await loadProjects();
await loadConfig();
// Update sync info periodically
syncInfoInterval = setInterval(updateSyncInfo, 5000);
});
// Cleanup
window.addEventListener('unload', () => {
if (syncInfoInterval) {
clearInterval(syncInfoInterval);
}
});
</script>
</body>