super-productivity/packages/plugin-dev/sync-md-plugin/plugin.js
Johannes Millan a968b7e9d8 feat(plugin): migrate all plugins from src/assets to plugin-dev directory
- Migrated api-test-plugin, sync-md-plugin, and yesterday-tasks-plugin to new plugin-dev structure
- Updated plugin service to load from both legacy and new locations
- Modified build-all script to handle plugin migration during build
- Maintained backward compatibility by copying built plugins to assets directory
2025-06-24 21:04:26 +02:00

1046 lines
33 KiB
JavaScript

// Sync.md Plugin for SuperProductivity
// This plugin syncs markdown files with project tasks
class SyncMdPlugin {
constructor() {
this.config = null;
this.watchInterval = null;
this.lastSyncTime = null;
this.lastFileContent = null;
this.lastModifiedTime = null;
this.isWatching = false;
this.syncInProgress = false;
this.ignoreNextFileChange = false;
this.orderChangeTimeout = null; // Debounce timer for order changes
}
async init() {
console.log('[Sync.md] Plugin initializing...');
// Register message handler for iframe communication
if (PluginAPI?.onMessage) {
PluginAPI.onMessage((message) => this.handleMessage(message));
}
// Register hook for task changes
if (PluginAPI?.registerHook && PluginAPI?.Hooks) {
// Use the correct hook names from PluginHooks enum
PluginAPI.registerHook(PluginAPI.Hooks.TASK_UPDATE, (task) => {
console.log('[Sync.md] Task update hook triggered:', task);
this.handleTaskUpdate(task);
});
PluginAPI.registerHook(PluginAPI.Hooks.TASK_DELETE, (taskId) => {
console.log('[Sync.md] Task delete hook triggered:', taskId);
this.handleTaskDeleted(taskId);
});
// Register ACTION hook to catch task order changes
PluginAPI.registerHook(PluginAPI.Hooks.ACTION, (action) => {
// Listen for task movement actions
if (
action &&
action.type &&
(action.type.includes('moveTask') ||
action.type.includes('reorder') ||
action.type.includes('Move Task'))
) {
console.log('[Sync.md] Task order change action detected:', action.type);
this.handleTaskOrderChange();
}
});
// Note: There's no direct "task added" hook, it comes through taskUpdate
}
// Load saved configuration
await this.loadConfig();
// Start watching if configured
if (this.config?.filePath && this.config?.projectId) {
// Always do an initial sync to ensure file matches project state
console.log('[Sync.md] Performing initial sync...');
if (this.config.syncDirection === 'fileToProject') {
// Read file and sync to project
await this.checkFileForChanges();
} else {
// Sync project to file first to ensure clean state
await this.syncProjectToFile();
}
// Then start watching for changes
await this.startWatching();
}
console.log('[Sync.md] Plugin initialized');
}
async handleMessage(message) {
console.log('[Sync.md] Received message:', message);
try {
switch (message.type) {
case 'configUpdated':
await this.saveConfig(message.config);
await this.stopWatching();
if (message.config?.filePath && message.config?.projectId) {
await this.startWatching();
}
return { success: true };
case 'testConnection':
return await this.testConnection(message.filePath);
case 'browseFile':
return await this.browseForFile(message.filters);
case 'readFile':
return await this.readFile(message.filePath);
case 'getSyncInfo':
// Get current task count
let taskCount = 0;
if (this.config?.projectId) {
const allTasks = await PluginAPI.getTasks();
taskCount = allTasks.filter(
(task) => task.projectId === this.config.projectId,
).length;
}
return {
isWatching: this.isWatching,
lastSyncTime: this.lastSyncTime,
taskCount: taskCount,
};
case 'syncNow':
console.log('[Sync.md] Manual sync requested');
// Always do a complete sync based on sync direction
if (this.config?.syncDirection === 'fileToProject') {
// Force read from file
this.lastModifiedTime = null; // Reset to force update
await this.checkFileForChanges();
} else {
// Force complete rebuild of markdown
await this.syncProjectToFile();
}
return { success: true };
default:
console.warn('[Sync.md] Unknown message type:', message.type);
return null;
}
} catch (error) {
console.error('[Sync.md] Error handling message:', error);
throw error;
}
}
async loadConfig() {
try {
const data = await PluginAPI.loadSyncedData();
if (data) {
// Data is returned as a string, need to parse it
const parsedData = typeof data === 'string' ? JSON.parse(data) : data;
// Extract the config from the data
this.config = parsedData.syncMdConfig || parsedData;
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;
// persistDataSynced expects a string
const dataToSave = JSON.stringify({ syncMdConfig: config });
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 executeNodeScript permission',
);
return;
}
try {
// Stop existing watcher if any
if (this.watchInterval) {
await this.stopWatching();
}
// Initial read
await this.checkFileForChanges();
// Start polling interval (check every 2 seconds)
this.watchInterval = setInterval(() => {
this.checkFileForChanges();
}, 2000);
this.isWatching = true;
console.log('[Sync.md] Started watching file:', this.config.filePath);
} catch (error) {
console.error('[Sync.md] Error starting watcher:', error);
this.isWatching = false;
}
}
async stopWatching() {
if (this.watchInterval) {
clearInterval(this.watchInterval);
this.watchInterval = null;
console.log('[Sync.md] Stopped watching file');
}
this.isWatching = false;
}
async checkFileForChanges() {
if (!this.config?.filePath || !PluginAPI?.executeNodeScript) {
return;
}
try {
const checkScript = `
const fs = require('fs');
const filePath = ${JSON.stringify(this.config.filePath)};
try {
const stats = fs.statSync(filePath);
const content = fs.readFileSync(filePath, 'utf8');
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: checkScript,
timeout: 5000,
});
if (result.success && result.result) {
const output = typeof result.result === 'string' ? result.result : '';
const data = JSON.parse(output);
if (data.exists && data.content) {
// Check if file has changed
const hasChanged =
this.lastModifiedTime !== data.modifiedTime ||
this.lastFileContent !== data.content;
if (hasChanged) {
// Check if we should ignore this change (from our own update)
if (this.ignoreNextFileChange) {
console.log('[Sync.md] Ignoring file change (self-triggered)');
this.ignoreNextFileChange = false;
this.lastModifiedTime = data.modifiedTime;
this.lastFileContent = data.content;
return;
}
console.log('[Sync.md] File changed, syncing...');
this.lastModifiedTime = data.modifiedTime;
this.lastFileContent = data.content;
await this.syncFileToProject(data.content);
}
} else if (!data.exists) {
console.error('[Sync.md] File no longer exists:', data.error);
}
}
} catch (error) {
console.error('[Sync.md] Error checking file:', error);
}
}
async syncFileToProject(content) {
if (
this.syncInProgress ||
!this.config?.projectId ||
this.config?.syncDirection === 'projectToFile'
) {
return;
}
this.syncInProgress = true;
try {
// Parse markdown content
const { tasks: markdownTasks } = this.parseMarkdown(content);
if (!markdownTasks || markdownTasks.length === 0) {
console.log('[Sync.md] No tasks found in markdown');
this.syncInProgress = false;
return;
}
// Get existing project tasks
const allTasks = await PluginAPI.getTasks();
const projectTasks = allTasks.filter(
(task) => task.projectId === this.config.projectId && !task.parentId,
);
// Sync structure by position
await this.syncTaskStructure(markdownTasks, projectTasks, allTasks);
this.lastSyncTime = new Date();
console.log('[Sync.md] File to project sync completed');
} catch (error) {
console.error('[Sync.md] Error syncing file to project:', error);
} finally {
this.syncInProgress = false;
}
}
parseMarkdown(content) {
const lines = content.split('\n');
const parsedLines = [];
let lineNumber = 0;
// First pass: parse all lines into structured data
for (const line of lines) {
lineNumber++;
// Calculate indentation level
const indentMatch = line.match(/^(\s*)/);
let indentLevel = 0;
if (indentMatch && indentMatch[1]) {
const whitespace = indentMatch[1];
// Count tabs as 1 level each, spaces as 1 level per 2 spaces
const tabCount = (whitespace.match(/\t/g) || []).length;
const spaceCount = (whitespace.match(/ /g) || []).length;
indentLevel = tabCount + Math.floor(spaceCount / 2);
}
const trimmed = line.trim();
if (!trimmed) continue;
// Match checkbox format: - [ ] title or - [x] title
const checkboxMatch = trimmed.match(/^-\s*\[([ x])\]\s*(.+)$/);
if (checkboxMatch) {
const isDone = checkboxMatch[1] === 'x';
const title = checkboxMatch[2].trim();
parsedLines.push({
title,
isDone,
lineNumber,
indentLevel,
originalLine: line,
});
continue;
}
// Also support plain bullet format: - title or * title
const bulletMatch = trimmed.match(/^[-*]\s+(.+)$/);
if (bulletMatch) {
const title = bulletMatch[1].trim();
parsedLines.push({
title,
isDone: false,
lineNumber,
indentLevel,
originalLine: line,
});
}
}
// Second pass: build hierarchical structure
const tasks = [];
let i = 0;
while (i < parsedLines.length) {
const currentLine = parsedLines[i];
// Only process top-level items (indentLevel 0) as main tasks
if (currentLine.indentLevel === 0) {
const task = {
title: currentLine.title,
isDone: currentLine.isDone,
lineNumber: currentLine.lineNumber,
indentLevel: currentLine.indentLevel,
subTasks: [],
};
// Look ahead for nested items
let j = i + 1;
let firstSubTaskLevel = null;
// Find all subtasks for this main task
while (j < parsedLines.length && parsedLines[j].indentLevel > 0) {
const subLine = parsedLines[j];
// Set the first subtask level if not set
if (firstSubTaskLevel === null) {
firstSubTaskLevel = subLine.indentLevel;
}
// Only add direct subtasks (at the first subtask level)
if (subLine.indentLevel === firstSubTaskLevel) {
task.subTasks.push({
title: subLine.title,
isDone: subLine.isDone,
lineNumber: subLine.lineNumber,
indentLevel: subLine.indentLevel,
});
}
// Skip deeper nested items (they would be sub-subtasks)
j++;
}
tasks.push(task);
i = j; // Skip the nested items we just processed
} else {
// This shouldn't happen if we process correctly, but skip just in case
i++;
}
}
console.log(
'[Sync.md] Parsed tasks:',
tasks.length,
'main tasks with subtasks:',
tasks.filter((t) => t.subTasks.length > 0).length,
);
// Debug: log task structure
tasks.forEach((task, i) => {
console.log(
`[Sync.md] Task ${i}: "${task.title}" - ${task.subTasks.length} subtasks`,
);
task.subTasks.forEach((subTask, j) => {
console.log(`[Sync.md] Subtask ${j}: "${subTask.title}"`);
});
});
return { tasks };
}
async getProjectTasks(projectId) {
try {
const tasks = await PluginAPI.getTasks();
return tasks.filter((task) => task.projectId === projectId);
} catch (error) {
console.error('[Sync.md] Error getting project tasks:', error);
return [];
}
}
async waitForTask(taskId, maxRetries = 10) {
for (let i = 0; i < maxRetries; i++) {
const tasks = await PluginAPI.getTasks();
const task = tasks.find((t) => t.id === taskId);
if (task) {
console.log(`[Sync.md] Task ${taskId} found after ${i + 1} attempts`);
return task;
}
// Wait a bit before retrying
await new Promise((resolve) => setTimeout(resolve, 50));
}
console.warn(`[Sync.md] Task ${taskId} not found after ${maxRetries} attempts`);
return null;
}
async syncTaskStructure(markdownTasks, projectMainTasks, allProjectTasks) {
console.log('[Sync.md] Syncing task structure...');
const mainTaskIds = [];
let hasNewMainTasks = false;
// Process each markdown task by position
for (let i = 0; i < markdownTasks.length; i++) {
const mdTask = markdownTasks[i];
let projectTask = projectMainTasks[i];
// If there's no project task at this position, create one
if (!projectTask) {
console.log(`[Sync.md] Creating task at position ${i}: ${mdTask.title}`);
const taskId = await PluginAPI.addTask({
title: mdTask.title,
projectId: this.config.projectId,
isDone: mdTask.isDone,
});
mainTaskIds.push(taskId);
hasNewMainTasks = true;
// Wait for the task to be available in the store
await this.waitForTask(taskId);
// Handle subtasks
for (let j = 0; j < mdTask.subTasks.length; j++) {
const mdSubTask = mdTask.subTasks[j];
console.log(`[Sync.md] Creating subtask: ${mdSubTask.title}`);
const subTaskId = await PluginAPI.addTask({
title: mdSubTask.title,
parentId: taskId,
projectId: this.config.projectId,
isDone: mdSubTask.isDone,
});
// Wait for the subtask to be available
await this.waitForTask(subTaskId);
}
} else {
// Update existing task if needed
mainTaskIds.push(projectTask.id);
if (projectTask.title !== mdTask.title || projectTask.isDone !== mdTask.isDone) {
console.log(`[Sync.md] Updating task at position ${i}: ${mdTask.title}`);
await PluginAPI.updateTask(projectTask.id, {
title: mdTask.title,
isDone: mdTask.isDone,
});
}
// Sync subtasks
const projectSubTasks = allProjectTasks.filter(
(task) => task.parentId === projectTask.id,
);
console.log(
`[Sync.md] Syncing subtasks for "${projectTask.title}" (${projectTask.id}): ${projectSubTasks.length} existing, ${mdTask.subTasks.length} in markdown`,
);
// Debug: log what we're about to sync
console.log(`[Sync.md] Markdown subtasks to sync:`);
mdTask.subTasks.forEach((st, idx) => {
console.log(`[Sync.md] ${idx}: "${st.title}"`);
});
await this.syncSubTasks(mdTask.subTasks, projectSubTasks, projectTask.id);
}
}
// Handle extra project tasks that don't exist in markdown
for (let i = markdownTasks.length; i < projectMainTasks.length; i++) {
const taskToRemove = projectMainTasks[i];
console.log(
`[Sync.md] WARNING: Task at position ${i} exists in project but not in markdown: "${taskToRemove.title}" (${taskToRemove.id})`,
);
console.log(
`[Sync.md] Cannot delete task via API. Marking as done and prefixing with [TO DELETE]:`,
);
// Mark the orphaned task as done and prefix its title
await PluginAPI.updateTask(taskToRemove.id, {
title: `[TO DELETE] ${taskToRemove.title}`,
isDone: true,
});
// Add to main task IDs at the end so they don't interfere with active tasks
mainTaskIds.push(taskToRemove.id);
}
// Reorder ONLY main tasks in the project (not subtasks!)
// Subtasks should only exist in their parent's subTaskIds array
if (mainTaskIds.length > 0 && PluginAPI.reorderTasks) {
// Always validate task IDs before reordering to prevent errors
const currentTasks = await PluginAPI.getTasks();
console.log(
`[Sync.md] Validating ${mainTaskIds.length} task IDs for project ${this.config.projectId}`,
);
const validTaskIds = mainTaskIds.filter((taskId) => {
const task = currentTasks.find((t) => t.id === taskId);
if (!task) {
console.log(`[Sync.md] Task ${taskId} not found in current tasks`);
return false;
}
const isValid = task.projectId === this.config.projectId && !task.parentId;
console.log(
`[Sync.md] Task ${taskId} validation: projectId=${task.projectId} (expected ${this.config.projectId}), parentId=${task.parentId}, valid=${isValid}`,
);
return isValid;
});
if (validTaskIds.length > 0) {
// Filter out any tasks that start with [TO DELETE] from the main order
const tasksToReorder = validTaskIds.filter((taskId) => {
const task = currentTasks.find((t) => t.id === taskId);
return task && !task.title.startsWith('[TO DELETE]');
});
// Add [TO DELETE] tasks at the end
const deleteTasks = validTaskIds.filter((taskId) => {
const task = currentTasks.find((t) => t.id === taskId);
return task && task.title.startsWith('[TO DELETE]');
});
const finalTaskOrder = [...tasksToReorder, ...deleteTasks];
console.log('[Sync.md] Reordering main tasks in project:', finalTaskOrder);
console.log(
`[Sync.md] Original task IDs: ${mainTaskIds.length}, Valid task IDs: ${validTaskIds.length}, Final order: ${finalTaskOrder.length} (${tasksToReorder.length} active, ${deleteTasks.length} marked for deletion)`,
);
try {
await PluginAPI.reorderTasks(finalTaskOrder, this.config.projectId, 'project');
} catch (error) {
console.error('[Sync.md] Error reordering tasks:', error);
console.error('[Sync.md] Failed task IDs:', validTaskIds);
console.error('[Sync.md] Project ID:', this.config.projectId);
}
} else {
console.log(
'[Sync.md] No valid tasks to reorder for project:',
this.config.projectId,
);
}
}
}
async syncSubTasks(markdownSubTasks, projectSubTasks, parentId) {
const subTaskIds = [];
let hasNewSubTasks = false;
console.log(
`[Sync.md] syncSubTasks called for parent ${parentId}: ${markdownSubTasks.length} markdown subtasks, ${projectSubTasks.length} existing subtasks`,
);
// Log all existing subtasks
projectSubTasks.forEach((task, i) => {
console.log(`[Sync.md] Existing subtask ${i}: "${task.title}" (${task.id})`);
});
// Process each markdown subtask by position
for (let i = 0; i < markdownSubTasks.length; i++) {
const mdSubTask = markdownSubTasks[i];
let projectSubTask = projectSubTasks[i];
console.log(`[Sync.md] Processing markdown subtask ${i}: "${mdSubTask.title}"`);
// If there's no project subtask at this position, create one
if (!projectSubTask) {
console.log(
`[Sync.md] Creating subtask at position ${i}: "${mdSubTask.title}" for parent ${parentId}`,
);
const subTaskId = await PluginAPI.addTask({
title: mdSubTask.title,
parentId: parentId,
projectId: this.config.projectId,
isDone: mdSubTask.isDone,
});
subTaskIds.push(subTaskId);
hasNewSubTasks = true;
// Wait for the subtask to be available
const createdTask = await this.waitForTask(subTaskId);
if (createdTask) {
console.log(
`[Sync.md] Successfully created subtask "${createdTask.title}" with id ${subTaskId}`,
);
} else {
console.error(
`[Sync.md] ERROR: Failed to create subtask "${mdSubTask.title}" - task ${subTaskId} not found after creation!`,
);
}
} else {
// Update existing subtask if needed
subTaskIds.push(projectSubTask.id);
if (
projectSubTask.title !== mdSubTask.title ||
projectSubTask.isDone !== mdSubTask.isDone
) {
console.log(
`[Sync.md] Updating subtask at position ${i}: "${projectSubTask.title}" -> "${mdSubTask.title}"`,
);
await PluginAPI.updateTask(projectSubTask.id, {
title: mdSubTask.title,
isDone: mdSubTask.isDone,
});
} else {
console.log(
`[Sync.md] Subtask at position ${i} unchanged: "${projectSubTask.title}"`,
);
}
}
}
// Handle extra project subtasks that don't exist in markdown
for (let i = markdownSubTasks.length; i < projectSubTasks.length; i++) {
const subTaskToRemove = projectSubTasks[i];
console.log(
`[Sync.md] WARNING: Subtask at position ${i} exists in project but not in markdown: "${subTaskToRemove.title}" (${subTaskToRemove.id})`,
);
console.log(
`[Sync.md] Cannot delete task via API. Marking as done and prefixing with [TO DELETE]:`,
);
// Mark the orphaned subtask as done and prefix its title
await PluginAPI.updateTask(subTaskToRemove.id, {
title: `[TO DELETE] ${subTaskToRemove.title}`,
isDone: true,
});
// Add to subtask IDs at the end so they don't interfere with active tasks
subTaskIds.push(subTaskToRemove.id);
}
// Reorder subtasks if needed (skip if we just created new subtasks)
if (subTaskIds.length > 0 && PluginAPI.reorderTasks && !hasNewSubTasks) {
// Double-check that all subtasks still belong to this parent
// (they might have been created but not yet added to parent's subTaskIds)
const currentTasks = await PluginAPI.getTasks();
const parentTask = currentTasks.find((t) => t.id === parentId);
if (parentTask) {
// Check if parent has subTaskIds array (it might be undefined or empty)
if (parentTask.subTaskIds && parentTask.subTaskIds.length > 0) {
// Include all subtask IDs, even orphaned ones we're marking for deletion
const validSubTaskIds = subTaskIds;
// Separate active tasks from [TO DELETE] tasks
const activeTasks = [];
const deleteTasks = [];
for (const taskId of validSubTaskIds) {
const task = currentTasks.find((t) => t.id === taskId);
if (task && task.title.startsWith('[TO DELETE]')) {
deleteTasks.push(taskId);
} else {
activeTasks.push(taskId);
}
}
// Put active tasks first, then [TO DELETE] tasks
const finalSubTaskOrder = [...activeTasks, ...deleteTasks];
if (finalSubTaskOrder.length > 0) {
console.log(
`[Sync.md] Reordering ${finalSubTaskOrder.length} subtasks for parent ${parentId} (${activeTasks.length} active, ${deleteTasks.length} marked for deletion)`,
);
await PluginAPI.reorderTasks(finalSubTaskOrder, parentId, 'task');
} else {
console.log(`[Sync.md] No valid subtasks to reorder for parent ${parentId}`);
}
} else {
console.log(
`[Sync.md] Parent task ${parentId} has no subtasks in subTaskIds array yet (might be newly created)`,
);
}
} else {
console.log(`[Sync.md] Parent task ${parentId} not found`);
}
} else if (hasNewSubTasks) {
console.log(
`[Sync.md] Skipping reorder - new subtasks were created and are already in correct order`,
);
}
}
async testConnection(filePath) {
if (!PluginAPI?.executeNodeScript) {
throw new Error('Node execution permission required');
}
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)
}));
} 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 browseForFile(filters) {
if (!PluginAPI?.executeNodeScript) {
throw new Error('Node execution permission required');
}
// Use electron dialog if available
const browseScript = `
const { dialog } = require('electron');
const result = dialog.showOpenDialogSync({
properties: ['openFile'],
filters: ${JSON.stringify(filters || [])}
});
console.log(JSON.stringify({
filePath: result ? result[0] : null
}));
`;
try {
const result = await PluginAPI.executeNodeScript({
script: browseScript,
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 || 'Browse operation failed');
}
} catch (error) {
console.error('[Sync.md] Error browsing for file:', error);
return { filePath: null };
}
}
async readFile(filePath) {
if (!PluginAPI?.executeNodeScript) {
throw new Error('Node execution permission required');
}
const readScript = `
const fs = require('fs');
const filePath = ${JSON.stringify(filePath)};
try {
const content = fs.readFileSync(filePath, 'utf8');
console.log(JSON.stringify({
content: content.substring(0, 1000) // Limit preview size
}));
} catch (error) {
console.log(JSON.stringify({
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 handleTaskUpdate(task) {
if (
this.syncInProgress ||
!this.config?.projectId ||
this.config?.syncDirection === 'fileToProject'
) {
return;
}
// Always do a complete sync when any task changes
// This ensures the markdown file always reflects the exact state
console.log('[Sync.md] Task update detected, performing complete sync...');
await this.syncProjectToFile();
}
async handleTaskDeleted(taskId) {
if (
this.syncInProgress ||
!this.config?.projectId ||
this.config?.syncDirection === 'fileToProject'
) {
return;
}
// Always sync when a task is deleted to ensure markdown reflects current state
console.log('[Sync.md] Task deleted from project, syncing to file...');
await this.syncProjectToFile();
}
async handleTaskOrderChange() {
if (
this.syncInProgress ||
!this.config?.projectId ||
this.config?.syncDirection === 'fileToProject'
) {
return;
}
// Debounce rapid order changes
if (this.orderChangeTimeout) {
clearTimeout(this.orderChangeTimeout);
}
this.orderChangeTimeout = setTimeout(async () => {
console.log('[Sync.md] Processing task order change...');
await this.syncProjectToFile();
}, 500); // Wait 500ms to batch multiple order changes
}
async syncProjectToFile() {
if (!this.config?.filePath || !PluginAPI?.executeNodeScript) {
return;
}
this.syncInProgress = true;
console.log('[Sync.md] Starting complete project to file sync...');
try {
// Get ALL tasks from the API
const allTasks = await PluginAPI.getTasks();
// Filter for this project's tasks
const projectTasks = allTasks.filter(
(task) => task.projectId === this.config.projectId,
);
console.log(
`[Sync.md] Found ${projectTasks.length} tasks for project ${this.config.projectId}`,
);
// Separate main tasks and subtasks, excluding [TO DELETE] tasks
const mainTasks = projectTasks.filter(
(task) => !task.parentId && !task.title.startsWith('[TO DELETE]'),
);
const subTasksByParent = new Map();
// Group subtasks by parent, excluding [TO DELETE] tasks
projectTasks.forEach((task) => {
if (task.parentId && !task.title.startsWith('[TO DELETE]')) {
if (!subTasksByParent.has(task.parentId)) {
subTasksByParent.set(task.parentId, []);
}
subTasksByParent.get(task.parentId).push(task);
}
});
console.log(`[Sync.md] Building markdown for ${mainTasks.length} main tasks`);
// Build markdown content from scratch
const markdownLines = [];
// Process each main task in order
for (const task of mainTasks) {
// Add main task with checkbox
const checkbox = task.isDone ? '[x]' : '[ ]';
markdownLines.push(`- ${checkbox} ${task.title}`);
// Get subtasks for this main task
const subTasks = subTasksByParent.get(task.id) || [];
// Add each subtask
for (const subTask of subTasks) {
const subCheckbox = subTask.isDone ? '[x]' : '[ ]';
markdownLines.push(` - ${subCheckbox} ${subTask.title}`);
// Add notes if present
if (subTask.notes) {
const noteLines = subTask.notes.split('\n');
noteLines.forEach((line) => {
if (line.trim()) {
markdownLines.push(` ${line}`);
}
});
}
}
// Add main task notes if present
if (task.notes && subTasks.length === 0) {
const noteLines = task.notes.split('\n');
noteLines.forEach((line) => {
if (line.trim()) {
markdownLines.push(` ${line}`);
}
});
}
}
const markdownContent = markdownLines.join('\n');
// Always write the file to ensure it's in sync
console.log('[Sync.md] Writing complete content to file...');
// Set flag to ignore the file change event
this.ignoreNextFileChange = true;
// Write complete content to file
const writeScript = `
const fs = require('fs');
const content = ${JSON.stringify(markdownContent)};
const filePath = ${JSON.stringify(this.config.filePath)};
try {
fs.writeFileSync(filePath, content, 'utf8');
console.log('File written successfully');
} catch (error) {
console.error('Write error:', error.message);
throw error;
}
`;
const result = await PluginAPI.executeNodeScript({
script: writeScript,
timeout: 5000,
});
if (!result.success) {
throw new Error(result.error || 'Write operation failed');
}
// Update our cached content
this.lastFileContent = markdownContent;
this.lastSyncTime = new Date();
console.log(`[Sync.md] File updated successfully with ${mainTasks.length} tasks`);
} catch (error) {
console.error('[Sync.md] Error syncing project to file:', error);
// Don't update lastFileContent on error
} finally {
this.syncInProgress = false;
}
}
destroy() {
console.log('[Sync.md] Plugin destroying...');
this.stopWatching();
if (this.orderChangeTimeout) {
clearTimeout(this.orderChangeTimeout);
this.orderChangeTimeout = null;
}
}
}
// Initialize plugin
const syncMdPlugin = new SyncMdPlugin();
syncMdPlugin.init();
// Register plugin for cleanup
if (PluginAPI?.onDestroy) {
PluginAPI.onDestroy(() => syncMdPlugin.destroy());
}