super-productivity/packages/plugin-dev/sync-md/plugin.js
Johannes Millan c9723cb6c9 feat(plugins): enhance plugin system with improved iframe communication and sync-md updates
- Add triggerSync method to plugin bridge for sync operations
- Enhance plugin iframe utility with better message handling and hook support
- Update sync-md plugin with improved error handling and sync state management
- Add plugin context validation for sync operations
- Update plugin API types with proper exports
- Improve bidirectional sync in sync-md plugin
- Add translation constant for sync context validation
2025-06-24 21:04:38 +02:00

523 lines
15 KiB
JavaScript

// Sync.md Plugin for SuperProductivity (Solid.js version)
// This plugin syncs markdown files with project tasks using a Solid.js UI
class SyncMdPlugin {
constructor() {
this.config = null;
this.watchInterval = null;
this.lastSyncTime = null;
this.syncInProgress = false;
this.bidirectionalSync = null; // Will be initialized when needed
this.syncState = null;
}
async init() {
console.log('[Sync.md] Plugin initializing (Solid.js version)...');
// Register message handler for iframe communication
if (PluginAPI?.onMessage) {
PluginAPI.onMessage(async (message) => {
console.log('[Sync.md] Received plugin message:', message);
try {
const response = await this.handleMessage(message.message || message);
console.log('[Sync.md] Sending response:', response);
// Return the response directly - the plugin framework will handle sending it back
return response;
} catch (error) {
console.error('[Sync.md] Error handling message:', error);
return { success: false, error: error.message };
}
});
}
// Register hooks for task changes
if (PluginAPI?.registerHook && PluginAPI?.Hooks) {
PluginAPI.registerHook(PluginAPI.Hooks.TASK_UPDATE, (task) => {
this.handleTaskUpdate(task);
});
PluginAPI.registerHook(PluginAPI.Hooks.TASK_DELETE, (taskId) => {
this.handleTaskDeleted(taskId);
});
}
// Load saved configuration
await this.loadConfig();
// Start watching if configured
if (this.config?.filePath && this.config?.projectId && this.config?.enabled) {
await this.startWatching();
}
console.log('[Sync.md] Plugin initialized');
}
async handleMessage(message) {
console.log('[Sync.md] Received message:', message);
console.log('[Sync.md] Message type:', message?.type);
console.log('[Sync.md] Full message object:', JSON.stringify(message, null, 2));
try {
switch (message.type) {
case 'configUpdated':
await this.saveConfig(message.config);
await this.stopWatching();
if (
message.config?.filePath &&
message.config?.projectId &&
message.config?.enabled
) {
await this.startWatching();
}
return { success: true };
case 'testFile':
const testResult = await this.testFile(message.filePath);
return testResult;
case 'checkDesktopMode':
return {
isDesktop: !!PluginAPI?.executeNodeScript,
};
case 'getSyncInfo':
const taskCount = await this.getTaskCount();
return {
isWatching: !!this.watchInterval,
lastSyncTime: this.lastSyncTime,
taskCount: taskCount,
};
case 'syncNow':
console.log('[Sync.md] Manual sync requested');
try {
const syncResult = await this.performSync();
if (syncResult === null) {
return {
success: false,
error:
'Sync skipped - plugin not properly configured or already in progress',
};
}
return { success: true, result: syncResult };
} catch (error) {
console.error('[Sync.md] Sync error:', error);
return { success: false, error: error.message };
}
default:
console.warn('[Sync.md] Unknown message type:', message.type);
return null;
}
} catch (error) {
console.error('[Sync.md] Error handling message:', error);
return { success: false, error: error.message };
}
}
async loadConfig() {
try {
const data = await PluginAPI.loadSyncedData();
if (data) {
const parsedData = typeof data === 'string' ? JSON.parse(data) : data;
this.config = parsedData.config || parsedData;
this.syncState = parsedData.syncState || null;
console.log('[Sync.md] Loaded config:', this.config);
}
} catch (error) {
console.error('[Sync.md] Error loading config:', error);
}
}
async saveConfig(config) {
try {
this.config = config;
const dataToSave = JSON.stringify({
config: config,
syncState: this.syncState,
});
await PluginAPI.persistDataSynced(dataToSave);
console.log('[Sync.md] Config saved:', config);
return true;
} catch (error) {
console.error('[Sync.md] Error saving config:', error);
return false;
}
}
async startWatching() {
if (!this.config?.filePath || !PluginAPI?.executeNodeScript) {
console.warn('[Sync.md] Cannot start watching: missing config or permissions');
return;
}
try {
// Stop existing watcher if any
if (this.watchInterval) {
await this.stopWatching();
}
// Test if file exists before starting sync
try {
const testResult = await this.testFile(this.config.filePath);
if (!testResult.exists) {
console.warn(
'[Sync.md] File does not exist, skipping initial sync:',
this.config.filePath,
);
return;
}
} catch (error) {
console.warn(
'[Sync.md] Cannot access file, skipping initial sync:',
this.config.filePath,
error.message,
);
return;
}
// Initial sync
await this.performSync();
// Start polling interval (check every 2 seconds)
this.watchInterval = setInterval(() => {
this.checkForChanges();
}, 2000);
console.log('[Sync.md] Started watching file:', this.config.filePath);
} catch (error) {
console.error('[Sync.md] Error starting watcher:', error);
}
}
async stopWatching() {
if (this.watchInterval) {
clearInterval(this.watchInterval);
this.watchInterval = null;
console.log('[Sync.md] Stopped watching file');
}
}
async checkForChanges() {
if (this.syncInProgress) return;
try {
const fileData = await this.readFile(this.config.filePath);
if (fileData.exists && fileData.content) {
// Check if file has changed
const currentChecksum = this.calculateChecksum(fileData.content);
const lastChecksum = this.syncState?.fileChecksum;
if (currentChecksum !== lastChecksum) {
console.log('[Sync.md] File changed, syncing...');
await this.performSync();
}
}
} catch (error) {
console.error('[Sync.md] Error checking file:', error);
// Stop watching on persistent errors to prevent spam
await this.stopWatching();
}
}
async performSync() {
if (
this.syncInProgress ||
!this.config?.projectId ||
!this.config?.filePath ||
!this.config?.enabled ||
!PluginAPI?.executeNodeScript
) {
console.log('[Sync.md] Sync skipped due to:', {
syncInProgress: this.syncInProgress,
hasProjectId: !!this.config?.projectId,
hasFilePath: !!this.config?.filePath,
isEnabled: !!this.config?.enabled,
hasNodeScript: !!PluginAPI?.executeNodeScript,
});
return null; // Return null instead of undefined to indicate skip
}
this.syncInProgress = true;
try {
// Read current file content
const fileData = await this.readFile(this.config.filePath);
if (!fileData.exists || !fileData.content) {
throw new Error('File not found or empty');
}
// Get current project tasks
const allTasks = await PluginAPI.getTasks();
const projectTasks = allTasks.filter(
(task) => task.projectId === this.config.projectId,
);
// Initialize sync module if needed
if (!this.bidirectionalSync) {
// Dynamic import would be ideal here, but for plugin compatibility we'll inline the logic
this.bidirectionalSync = new SimpleBidirectionalSync();
}
// Perform sync based on direction
const syncResult = await this.bidirectionalSync.sync(
fileData.content,
projectTasks,
this.config.syncDirection,
this.syncState,
);
console.log('[Sync.md] Sync completed:', syncResult);
// Apply sync results
if (this.config.syncDirection !== 'projectToFile') {
await this.applyFileChangesToProject(syncResult, fileData.content);
}
if (this.config.syncDirection !== 'fileToProject') {
await this.applyProjectChangesToFile(syncResult, projectTasks);
}
// Update sync state
this.syncState = this.bidirectionalSync.updateSyncState(
fileData.content,
projectTasks,
);
await this.saveConfig(this.config);
this.lastSyncTime = new Date();
return syncResult;
} catch (error) {
console.error('[Sync.md] Sync error:', error);
throw error;
} finally {
this.syncInProgress = false;
}
}
async applyFileChangesToProject(syncResult, markdownContent) {
// Implementation would parse markdown and update project tasks
console.log('[Sync.md] Applying file changes to project...');
// This is simplified - real implementation would handle all the sync operations
}
async applyProjectChangesToFile(syncResult, projectTasks) {
// Implementation would generate markdown from project tasks
console.log('[Sync.md] Applying project changes to file...');
// This is simplified - real implementation would handle all the sync operations
}
async testFile(filePath) {
if (!PluginAPI?.executeNodeScript) {
throw new Error(
'Node execution not available. This plugin requires the desktop version of Super Productivity.',
);
}
const testScript = `
const fs = require('fs');
const path = require('path');
const filePath = ${JSON.stringify(filePath)};
try {
const stats = fs.statSync(filePath);
const content = fs.readFileSync(filePath, 'utf8');
console.log(JSON.stringify({
exists: true,
isFile: stats.isFile(),
size: stats.size,
content: content.substring(0, 500),
preview: content.substring(0, 500)
}));
} catch (error) {
console.log(JSON.stringify({
exists: false,
error: error.message
}));
}
`;
const result = await PluginAPI.executeNodeScript({
script: testScript,
timeout: 5000,
});
if (result.success && result.result) {
const output = typeof result.result === 'string' ? result.result : '';
return JSON.parse(output);
} else {
throw new Error(result.error || 'Test connection failed');
}
}
async readFile(filePath) {
if (!PluginAPI?.executeNodeScript) {
throw new Error(
'Node execution not available. This plugin requires the desktop version of Super Productivity.',
);
}
const readScript = `
const fs = require('fs');
const filePath = ${JSON.stringify(filePath)};
try {
const content = fs.readFileSync(filePath, 'utf8');
const stats = fs.statSync(filePath);
console.log(JSON.stringify({
exists: true,
content: content,
modifiedTime: stats.mtime.getTime()
}));
} catch (error) {
console.log(JSON.stringify({
exists: false,
error: error.message
}));
}
`;
const result = await PluginAPI.executeNodeScript({
script: readScript,
timeout: 5000,
});
if (result.success && result.result) {
const output = typeof result.result === 'string' ? result.result : '';
return JSON.parse(output);
} else {
throw new Error(result.error || 'Read operation failed');
}
}
async getTaskCount() {
if (!this.config?.projectId) return 0;
const allTasks = await PluginAPI.getTasks();
return allTasks.filter((task) => task.projectId === this.config.projectId).length;
}
calculateChecksum(content) {
let hash = 0;
for (let i = 0; i < content.length; i++) {
const char = content.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return hash.toString(36);
}
async handleTaskUpdate(task) {
if (
this.syncInProgress ||
!this.config?.projectId ||
!this.config?.filePath ||
!this.config?.enabled ||
!PluginAPI?.executeNodeScript ||
this.config?.syncDirection === 'fileToProject'
) {
return;
}
if (task.projectId === this.config.projectId) {
console.log('[Sync.md] Task update detected, scheduling sync...');
// Debounce to avoid too many syncs
if (this.syncTimeout) clearTimeout(this.syncTimeout);
this.syncTimeout = setTimeout(async () => {
try {
await this.performSync();
} catch (error) {
console.error('[Sync.md] Sync error:', error);
// Don't repeatedly try to sync if there's a persistent error
this.stopWatching();
}
}, 1000);
}
}
async handleTaskDeleted(taskId) {
// Similar to handleTaskUpdate
this.handleTaskUpdate({ projectId: this.config?.projectId });
}
destroy() {
console.log('[Sync.md] Plugin destroying...');
this.stopWatching();
if (this.syncTimeout) {
clearTimeout(this.syncTimeout);
this.syncTimeout = null;
}
}
}
// Simplified sync logic for the plugin (full implementation would import from syncLogic.ts)
class SimpleBidirectionalSync {
constructor() {
this.syncState = {
lastSyncTime: null,
fileChecksum: null,
taskChecksums: new Map(),
};
}
async sync(markdownContent, projectTasks, syncDirection, lastSyncState) {
if (lastSyncState) {
this.syncState = lastSyncState;
}
// This is a simplified version - real implementation would be more complex
const result = {
tasksAdded: 0,
tasksUpdated: 0,
tasksDeleted: 0,
conflicts: [],
};
// Count differences for demo purposes
const markdownTasks = this.parseMarkdown(markdownContent);
result.tasksAdded = Math.abs(markdownTasks.length - projectTasks.length);
return result;
}
parseMarkdown(content) {
const lines = content.split('\n');
const tasks = [];
lines.forEach((line) => {
const match = line.match(/^-\s*\[([ x])\]\s*(.+)$/);
if (match) {
tasks.push({
title: match[2].trim(),
isDone: match[1] === 'x',
});
}
});
return tasks;
}
updateSyncState(markdownContent, tasks) {
this.syncState.lastSyncTime = new Date();
this.syncState.fileChecksum = this.calculateChecksum(markdownContent);
return this.syncState;
}
calculateChecksum(content) {
let hash = 0;
for (let i = 0; i < content.length; i++) {
const char = content.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return hash.toString(36);
}
}
// Initialize plugin
const syncMdPlugin = new SyncMdPlugin();
syncMdPlugin.init();
// Register plugin for cleanup
if (PluginAPI?.onDestroy) {
PluginAPI.onDestroy(() => syncMdPlugin.destroy());
}