mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-24 03:06:16 +00:00
- 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
523 lines
15 KiB
JavaScript
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());
|
|
}
|