mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-08-02 12:32:14 +00:00
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:
parent
3d6f94ba0e
commit
30a962c2a9
17 changed files with 1650 additions and 810 deletions
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
138
packages/plugin-dev/sync-md/src/background/helper/file-utils.ts
Normal file
138
packages/plugin-dev/sync-md/src/background/helper/file-utils.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
28
packages/plugin-dev/sync-md/src/background/local-config.ts
Normal file
28
packages/plugin-dev/sync-md/src/background/local-config.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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];
|
||||
};
|
||||
|
|
@ -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,
|
||||
}));
|
||||
};
|
||||
46
packages/plugin-dev/sync-md/src/background/sync/md-to-sp.ts
Normal file
46
packages/plugin-dev/sync-md/src/background/sync/md-to-sp.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
151
packages/plugin-dev/sync-md/src/background/sync/sp-to-md.ts
Normal file
151
packages/plugin-dev/sync-md/src/background/sync/sp-to-md.ts
Normal 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}`;
|
||||
};
|
||||
177
packages/plugin-dev/sync-md/src/background/sync/sync-manager.ts
Normal file
177
packages/plugin-dev/sync-md/src/background/sync/sync-manager.ts
Normal 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'}`);
|
||||
});
|
||||
};
|
||||
236
packages/plugin-dev/sync-md/src/background/sync/verify-sync.ts
Normal file
236
packages/plugin-dev/sync-md/src/background/sync/verify-sync.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
102
packages/plugin-dev/sync-md/src/background/ui-bridge.ts
Normal file
102
packages/plugin-dev/sync-md/src/background/ui-bridge.ts
Normal 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' };
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
7
packages/plugin-dev/sync-md/src/types.d.ts
vendored
Normal file
7
packages/plugin-dev/sync-md/src/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { PluginAPI } from '@super-productivity/plugin-api';
|
||||
|
||||
declare global {
|
||||
const PluginAPI: PluginAPI;
|
||||
}
|
||||
|
||||
export {};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue