This commit is contained in:
Johannes Millan 2025-07-17 09:12:43 +02:00
parent 4b7bdf1b14
commit 1d2e80e552
15 changed files with 10 additions and 1383 deletions

View file

@ -3,19 +3,15 @@ import { initUiBridge } from './ui-bridge';
import { loadLocalConfig } from './local-config';
import { log } from '../shared/logger';
export const initPlugin = (): void => {
log.log('initPlugin called');
// Initialize UI bridge to handle messages
initUiBridge();
log.log('UI bridge initialized');
// Initialize UI bridge to handle messages
initUiBridge();
log.log('UI bridge initialized');
// Load saved config from local storage and start sync if enabled
const config = loadLocalConfig();
log.log('Loaded config:', config);
// Load saved config from local storage and start sync if enabled
const config = loadLocalConfig();
log.log('Loaded config:', config);
if (config?.filePath) {
// Transform config to match sync-manager expectations
initSyncManager(config);
}
};
if (config?.filePath) {
// Transform config to match sync-manager expectations
initSyncManager(config);
}

View file

@ -1,167 +0,0 @@
/**
* Sync-MD Plugin Background Script
* Initializes and coordinates all plugin components
*/
import { SyncConfig } from '../shared/types';
import { SimpleFileWatcher } from './simple-file-watcher';
import { SyncManager } from './sync-manager';
import { setupMessageHandler } from './message-handler';
import { IGNORED_TASK_ACTIONS, LOG_PREFIX, REGISTERED_HOOKS } from './config.const';
import { PluginHooks } from '@super-productivity/plugin-api';
// Global instances
let fileWatcher: SimpleFileWatcher | null = null;
const syncManager = new SyncManager();
/**
* Initialize the sync-md plugin
*/
const initPlugin = async (): Promise<void> => {
console.log(`${LOG_PREFIX.PLUGIN} Initializing...`);
if (typeof PluginAPI === 'undefined') {
console.error(`${LOG_PREFIX.PLUGIN} PluginAPI not available`);
return;
}
// Setup window focus tracking
setupWindowFocusTracking();
// Setup message handling
setupMessageHandler({
onConfigUpdated: handleConfigUpdate,
onSyncNow: () => syncManager.requestSync('Manual sync'),
});
// Setup hooks
setupHooks();
// Load saved config
await loadSavedConfig();
console.log(`${LOG_PREFIX.PLUGIN} Initialization complete`);
};
/**
* Setup window focus tracking
*/
const setupWindowFocusTracking = (): void => {
if (!PluginAPI.onWindowFocusChange) return;
PluginAPI.onWindowFocusChange((isFocused: boolean) => {
syncManager?.setWindowFocused(isFocused);
console.log(
`${LOG_PREFIX.SYNC} Window focus: ${isFocused ? 'focused' : 'unfocused'}`,
);
if (!isFocused && syncManager) {
syncManager.requestSync('Window focus lost');
}
});
};
/**
* Setup hooks for task updates
*/
const setupHooks = (): void => {
if (!PluginAPI.registerHook) {
console.warn(`${LOG_PREFIX.PLUGIN} No registerHook API`);
return;
}
// Register anyTaskUpdate hook
PluginAPI.registerHook(PluginHooks.ANY_TASK_UPDATE, async (payload) => {
const typedPayload = payload as { action?: string };
console.log(`${LOG_PREFIX.HOOK} Task update: ${typedPayload.action}`);
// Filter ignored actions
const shouldIgnore = IGNORED_TASK_ACTIONS.some((ignored) =>
typedPayload.action?.includes(ignored),
);
if (shouldIgnore) {
console.log(`${LOG_PREFIX.HOOK} Ignoring: ${typedPayload.action}`);
return;
}
syncManager?.requestSync(`Task update: ${typedPayload.action}`);
});
// Register other hooks
const otherHooks = REGISTERED_HOOKS.filter((h) => h !== 'anyTaskUpdate');
for (const hookName of otherHooks) {
try {
PluginAPI.registerHook(PluginHooks.PROJECT_LIST_UPDATE, async (payload) => {
const typedPayload = payload as { action?: string };
console.log(
`${LOG_PREFIX.HOOK} ${hookName}: ${typedPayload.action || 'unknown'}`,
);
syncManager?.requestSync(`${hookName}: ${typedPayload.action || 'unknown'}`);
});
} catch (e) {
// Hook might not exist
}
}
};
/**
* Handle configuration updates
*/
const handleConfigUpdate = async (config: SyncConfig): Promise<void> => {
console.log(`${LOG_PREFIX.BACKGROUND} Config updated:`, config);
// Stop existing watcher
if (fileWatcher) {
fileWatcher.stop();
fileWatcher = null;
}
if (!config.enabled) {
return;
}
if (!syncManager) {
return;
}
fileWatcher = new SimpleFileWatcher({
filePath: config.filePath,
onFileChange: (modifiedTime) => syncManager.handleFileChange(modifiedTime),
onError: (error) => {
console.error(`${LOG_PREFIX.FILE_WATCHER} Error:`, error);
PluginAPI.showSnack({
msg: `File watch error: ${error.message}`,
type: 'ERROR',
});
},
});
await fileWatcher.start();
await syncManager.requestSync('Initial configuration');
};
/**
* Load saved configuration
*/
const loadSavedConfig = async (): Promise<void> => {
const savedData = await PluginAPI.loadSyncedData?.();
if (!savedData) return;
try {
const config: SyncConfig = JSON.parse(savedData);
if (config.enabled && typeof PluginAPI.executeNodeScript === 'function') {
await handleConfigUpdate(config);
}
} catch (error) {
console.error('Failed to load saved config:', error);
}
};
// Start plugin
if (typeof PluginAPI !== 'undefined') {
console.log(`${LOG_PREFIX.PLUGIN} PluginAPI detected`);
Promise.resolve().then(() => initPlugin());
} else {
console.warn(`${LOG_PREFIX.PLUGIN} PluginAPI not found`);
}
export { initPlugin };

View file

@ -1,29 +0,0 @@
/**
* Configuration constants for the sync-md plugin
*/
// Sync timing configuration
export const SYNC_DEBOUNCE_TIME_FOCUSED = 500; // 0.5 seconds when SP is focused
export const SYNC_DEBOUNCE_TIME_UNFOCUSED = 2000; // 2 seconds when editing markdown
export const MIN_SYNC_INTERVAL = 5000; // 5 seconds minimum between syncs
// Plugin logging prefixes
export const LOG_PREFIX = {
SYNC: '[Sync]',
PLUGIN: '[Plugin]',
BACKGROUND: '[Background]',
FILE_WATCHER: '[FileWatcher]',
HOOK: '[Hook]',
PLUGIN_BUILD: '[Plugin Build]',
} as const;
// Hook names to register
export const REGISTERED_HOOKS = [
'anyTaskUpdate',
'projectUpdate',
'projectListUpdate',
'workContextUpdate',
] as const;
// Actions to ignore in task updates
export const IGNORED_TASK_ACTIONS = ['timeTracking'] as const;

View file

@ -1,171 +0,0 @@
import { ParsedTask } from './models/markdown.model';
import { SpTask } from './models/sync.model';
import { BatchOperation } from '@super-productivity/plugin-api';
/**
* Generate batch operations to sync markdown tasks to Super Productivity
*/
export const generateTaskOperations = (
mdTasks: ParsedTask[],
spTasks: SpTask[],
projectId: string,
): BatchOperation[] => {
const operations: BatchOperation[] = [];
// Create maps for easier lookup
const spById = new Map<string, SpTask>();
const spByTitle = new Map<string, SpTask>();
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);
} 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) => {
// Skip subtasks - they'll be handled with their parents
if (mdTask.isSubtask) {
return;
}
// 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.noteLines ? mdTask.noteLines.join('\n') : '';
if ((spTask.notes || '') !== mdNotes) {
updates.notes = mdNotes;
}
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: {
id: effectiveId,
title: mdTask.title,
isDone: mdTask.completed,
notes: mdTask.noteLines ? mdTask.noteLines.join('\n') : undefined,
projectId,
},
} 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);
// Update existing task
const updates: Record<string, unknown> = {};
if (spTask.isDone !== mdTask.completed) {
updates.isDone = mdTask.completed;
}
const mdNotes = mdTask.noteLines ? mdTask.noteLines.join('\n') : '';
if ((spTask.notes || '') !== mdNotes) {
updates.notes = mdNotes;
}
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.noteLines ? mdTask.noteLines.join('\n') : undefined,
projectId,
},
} as BatchTaskCreate);
}
}
});
// Second pass: delete tasks that exist in SP but not in MD
spTasks.forEach((spTask) => {
if (!processedSpIds.has(spTask.id) && !spTask.parentId) {
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) {
operations.push({
type: 'reorder',
taskIds: mdTaskIds,
} as BatchTaskReorder);
}
return operations;
};

View file

@ -1,176 +0,0 @@
import { parseMarkdownTasks } from '../../markdown-parser';
describe('markdown-parser', () => {
describe('parseMarkdownTasks', () => {
it('should parse simple tasks', () => {
const content = `
- [ ] Task 1
- [x] Task 2 completed
- [ ] <!-- sp:123 --> Task with ID
`.trim();
const result = parseMarkdownTasks(content);
expect(result.errors).toEqual([]);
expect(result.tasks).toHaveLength(3);
expect(result.tasks[0]).toEqual({
line: 0,
indent: 0,
completed: false,
id: null,
title: 'Task 1',
originalLine: '- [ ] Task 1',
parentId: null,
isSubtask: false,
originalId: null,
depth: 0,
});
expect(result.tasks[1]).toEqual({
line: 1,
indent: 0,
completed: true,
id: null,
title: 'Task 2 completed',
originalLine: '- [x] Task 2 completed',
parentId: null,
isSubtask: false,
originalId: null,
depth: 0,
});
expect(result.tasks[2]).toEqual({
line: 2,
indent: 0,
completed: false,
id: '123',
title: 'Task with ID',
originalLine: '- [ ] <!-- sp:123 --> Task with ID',
parentId: null,
isSubtask: false,
originalId: '123',
depth: 0,
});
});
it('should handle nested tasks', () => {
const content = `
- [ ] <!-- sp:parent1 --> Parent task
- [ ] Child task 1
- [x] <!-- sp:child2 --> Child task 2
- [ ] Sub-child task
- [ ] Another parent
`.trim();
const result = parseMarkdownTasks(content);
expect(result.errors).toEqual([]);
expect(result.tasks).toHaveLength(4); // Sub-child task is now treated as notes
// Parent task
expect(result.tasks[0]).toEqual({
line: 0,
indent: 0,
completed: false,
id: 'parent1',
title: 'Parent task',
originalLine: '- [ ] <!-- sp:parent1 --> Parent task',
parentId: null,
isSubtask: false,
originalId: 'parent1',
depth: 0,
});
// Child task 1
expect(result.tasks[1]).toEqual({
line: 1,
indent: 2,
completed: false,
id: null,
title: 'Child task 1',
originalLine: ' - [ ] Child task 1',
parentId: 'parent1',
isSubtask: true,
originalId: null,
depth: 1,
});
// Child task 2 (now has notes from the sub-child)
expect(result.tasks[2]).toEqual({
line: 2,
indent: 2,
completed: true,
id: 'child2',
title: 'Child task 2',
originalLine: ' - [x] <!-- sp:child2 --> Child task 2',
parentId: 'parent1',
isSubtask: true,
originalId: 'child2',
depth: 1,
noteLines: [' - [ ] Sub-child task'],
});
// Another parent (sub-child is now notes, so this is index 3)
expect(result.tasks[3]).toEqual({
line: 4,
indent: 0,
completed: false,
id: null,
title: 'Another parent',
originalLine: '- [ ] Another parent',
parentId: null,
isSubtask: false,
originalId: null,
depth: 0,
});
});
it('should skip empty task titles', () => {
const content = `
- [ ]
- [x] Valid task
- [ ] <!-- sp:123 -->
`.trim();
const result = parseMarkdownTasks(content);
expect(result.errors).toEqual([
'Skipping task with empty title at line 1',
'Skipping task with empty title at line 3',
]);
expect(result.tasks).toHaveLength(1);
expect(result.tasks[0].title).toBe('Valid task');
});
it('should ignore non-task lines', () => {
const content = `
# Heading
Some text
- [ ] Task 1
More text
- [x] Task 2
`.trim();
const result = parseMarkdownTasks(content);
expect(result.errors).toEqual([]);
expect(result.tasks).toHaveLength(2);
expect(result.tasks[0].title).toBe('Task 1');
expect(result.tasks[1].title).toBe('Task 2');
});
it('should handle various ID formats', () => {
const content = `
- [ ] <!-- sp:simple-id --> Task 1
- [x] <!-- sp:123-456-789 --> Task 2
- [ ] <!-- sp:uuid-like-id-here --> Task 3
`.trim();
const result = parseMarkdownTasks(content);
expect(result.errors).toEqual([]);
expect(result.tasks).toHaveLength(3);
expect(result.tasks[0].id).toBe('simple-id');
expect(result.tasks[1].id).toBe('123-456-789');
expect(result.tasks[2].id).toBe('uuid-like-id-here');
});
});
});

View file

@ -1,170 +0,0 @@
// Pure markdown parsing utilities - easily testable
import { ParsedTask, TaskParseResult } from './models/markdown.model';
/**
* Detect the base indentation size used in the markdown
* Returns the smallest non-zero indentation found
*/
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);
}
}
// Default to 2 if no indentation found
return minIndent === Infinity ? 2 : minIndent;
};
/**
* Pure function to parse markdown content into task objects
* This is easily testable as it has no side effects
*/
export const parseMarkdownTasks = (content: string): TaskParseResult => {
const lines = content.split('\n');
const tasks: ParsedTask[] = [];
const errors: string[] = [];
const parentStack: Array<{
indent: number;
id: string | null;
line: number;
taskIndex: number;
}> = [];
const seenIds = new Set<string>();
// Detect the indentation size used in this file
const detectedIndentSize = detectIndentSize(lines);
console.log(`📏 Detected indent size: ${detectedIndentSize} spaces per level`);
// First pass: identify all task lines and parse tasks
lines.forEach((line, index) => {
// Try new format first (no sp: prefix, no spaces)
let taskMatch = line.match(/^(\s*)- \[([ x])\]\s*(?:<!--([^>]+)-->)?\s*(.*)$/);
// If no match, try old format (with sp: prefix and spaces)
if (!taskMatch) {
taskMatch = line.match(/^(\s*)- \[([ x])\]\s*(?:<!-- sp:([^>]+) -->)?\s*(.*)$/);
}
if (taskMatch) {
const [, indent, completed, id, title] = taskMatch;
const indentLevel = indent.length;
// Calculate depth using actual detected indent size
let depth: number;
if (detectedIndentSize === 1) {
// Special handling for 1-space indentation
// 0 spaces = depth 0, 1 space = depth 1, 2+ spaces = depth 2+
depth = indentLevel;
} else {
// Normal calculation for 2+ space indentation
depth = detectedIndentSize > 0 ? Math.floor(indentLevel / detectedIndentSize) : 0;
}
// Only process tasks at depth 0 or 1 (parent tasks and subtasks)
if (depth <= 1) {
const isSubtask = indentLevel > 0;
let parentId: string | null = null;
if (isSubtask) {
// Find parent from stack
for (let i = parentStack.length - 1; i >= 0; i--) {
if (parentStack[i].indent < indentLevel) {
parentId = parentStack[i].id;
break;
}
}
// Clean up stack
while (
parentStack.length > 0 &&
parentStack[parentStack.length - 1].indent >= indentLevel
) {
parentStack.pop();
}
} else {
parentStack.length = 0;
}
// Check for duplicate IDs
let actualId: string | null = id || null;
if (id && seenIds.has(id)) {
errors.push(`Duplicate task ID "${id}" at line ${index + 1} - ID removed`);
actualId = null;
} else if (id) {
seenIds.add(id);
}
// Skip tasks with empty titles
if (!title.trim()) {
errors.push(`Skipping task with empty title at line ${index + 1}`);
return; // Skip this iteration in forEach
}
const taskIndex = tasks.length;
tasks.push({
line: index,
indent: indentLevel,
completed: completed === 'x',
id: actualId || null,
title: title.trim(),
originalLine: line,
parentId,
isSubtask,
originalId: id || null,
depth,
});
// Add to parent stack
parentStack.push({
indent: indentLevel,
id: actualId || null,
line: index,
taskIndex,
});
}
}
});
// Second pass: collect notes for all tasks at depth 0 and 1
// Notes are any lines between a task and the next task
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i];
// Both parent tasks (depth 0) and subtasks (depth 1) can have notes
if (task.depth <= 1) {
const startLine = task.line + 1;
let endLine = lines.length;
// Find the next task line
for (let j = i + 1; j < tasks.length; j++) {
if (tasks[j].line > task.line) {
endLine = tasks[j].line;
break;
}
}
// Collect all lines between this task and the next as notes
const noteLines: string[] = [];
for (let lineNum = startLine; lineNum < endLine; lineNum++) {
const line = lines[lineNum];
// Include all lines, preserving empty lines
noteLines.push(line);
}
// Trim trailing empty lines
while (noteLines.length > 0 && !noteLines[noteLines.length - 1].trim()) {
noteLines.pop();
}
if (noteLines.length > 0) {
task.noteLines = noteLines;
}
}
}
return { tasks, errors, detectedIndentSize };
};

View file

@ -1,106 +0,0 @@
/**
* Handles messages between UI and background
*/
import { SyncConfig } from '../shared/types';
import { LOG_PREFIX } from './config.const';
import { MessageCallbacks } from './models/message.model';
import { readFileContent } from './helper/node-file-helper';
export const setupMessageHandler = (callbacks: MessageCallbacks): void => {
if (!PluginAPI.onMessage) {
console.error(`${LOG_PREFIX.PLUGIN} No onMessage API`);
return;
}
PluginAPI.onMessage(async (message: unknown) => {
console.log('Message received:', message);
try {
if (typeof message !== 'object' || message === null || !('type' in message)) {
return { success: false, error: 'Invalid message' };
}
const msg = message as { type: string; config?: SyncConfig; filePath?: string };
switch (msg.type) {
case 'configUpdated':
if (msg.config) {
await callbacks.onConfigUpdated(msg.config);
}
return { success: true };
case 'syncNow':
await callbacks.onSyncNow();
return { success: true };
case 'testFile':
return await handleTestFile(msg.filePath);
// TODO can be called from ui directly
case 'getProjects':
return await handleGetProjects();
// TODO can be called from ui directly
case 'getTasks':
return await handleGetTasks();
case 'checkDesktopMode':
return handleCheckDesktopMode();
default:
return { success: false, error: `Unknown type: ${msg.type}` };
}
} catch (error) {
console.error('Message error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
});
};
export const handleTestFile = async (
filePath: string,
): Promise<{ success: boolean; preview?: string; error?: string }> => {
if (!filePath) {
return { success: false, error: 'No file path provided' };
}
try {
// Test file access
const content = await readFileContent(filePath);
const lines = content.split('\n').slice(0, 10);
return {
success: true,
preview: lines.join('\n') + (content.split('\n').length > 10 ? '\n...' : ''),
};
} catch (error) {
return { success: false, error: (error as any)?.message };
}
};
export const handleGetProjects = async (): Promise<{
success: boolean;
projects: any[];
}> => {
// Always get fresh data
const projects = await PluginAPI.getAllProjects();
return { success: true, projects };
};
export const handleGetTasks = async (): Promise<{ success: boolean; tasks: any[] }> => {
// Always get fresh data
const tasks = await PluginAPI.getTasks();
return { success: true, tasks };
};
export const handleCheckDesktopMode = (): { success: boolean; isDesktop: boolean } => {
// Check if we're in Electron environment with Node.js capabilities
return {
success: true,
isDesktop: typeof PluginAPI?.executeNodeScript === 'function',
};
};

View file

@ -1,25 +0,0 @@
/**
* Models for file operations and watching
*/
export interface FileWatcherOptions {
filePath: string;
onFileChange: (modifiedTime: number) => void;
onError: (error: Error) => void;
}
export interface NodeScriptOptions {
script: string;
args: unknown[];
timeout: number;
}
export interface NodeScriptResult {
success: boolean;
result?: unknown;
error?: string;
}
export interface PluginAPILike {
executeNodeScript(options: NodeScriptOptions): Promise<NodeScriptResult>;
}

View file

@ -1,23 +0,0 @@
/**
* Models for markdown parsing and task representation
*/
export interface ParsedTask {
line: number;
indent: number;
completed: boolean;
id: string | null;
title: string;
originalLine: string;
parentId?: string | null;
isSubtask: boolean;
originalId?: string | null; // Track the original ID even if it's a duplicate
depth: number; // Track depth level (0 = root, 1 = subtask, 2+ = notes content)
noteLines?: string[]; // Third-level content to be synced as notes
}
export interface TaskParseResult {
tasks: ParsedTask[];
errors: string[];
detectedIndentSize?: number;
}

View file

@ -1,10 +0,0 @@
/**
* Models for UI-background communication
*/
import { SyncConfig } from '../../shared/types';
export interface MessageCallbacks {
onConfigUpdated: (config: SyncConfig) => Promise<void>;
onSyncNow: () => Promise<void>;
}

View file

@ -1,20 +0,0 @@
/**
* Models for sync operations
*/
export interface SyncResult {
success: boolean;
taskCount?: number;
error?: string;
type: 'file-to-sp' | 'sp-to-file' | 'integrity-check';
}
export interface SpTask {
id: string;
title: string;
projectId?: string;
parentId?: string;
isDone: boolean;
notes?: string;
subTaskIds?: string[];
}

View file

@ -1,81 +0,0 @@
import { PluginFileOperations } from './helper/file-operations';
import { Debouncer } from './helper/debouncer';
import { FileOperations } from './models/file-operations.model';
import { FileWatcherOptions } from './models/file-operations.model';
export class SimpleFileWatcher {
private fileOps: FileOperations;
private watchHandle: unknown = null;
private isWatching: boolean = false;
private debouncer = new Debouncer();
private lastSeenModifiedTime: number = 0;
constructor(private options: FileWatcherOptions) {
this.fileOps = new PluginFileOperations(PluginAPI);
}
/**
* Start watching the file for changes
*/
async start(): Promise<void> {
if (this.isWatching) {
console.log('📁 File watcher already running');
return;
}
try {
// Get initial file state
const stats = await this.fileOps.getFileStats(this.options.filePath);
this.lastSeenModifiedTime = stats.modifiedTime;
// Start watching
this.watchHandle = await this.fileOps.watchFile(this.options.filePath, async () => {
// Debounce file change events
this.debouncer.debounce(
'file-change',
async () => {
try {
const fileStats = await this.fileOps.getFileStats(this.options.filePath);
// Only trigger if modified time actually changed
if (fileStats.modifiedTime > this.lastSeenModifiedTime) {
console.log('📄 File change detected');
this.lastSeenModifiedTime = fileStats.modifiedTime;
this.options.onFileChange(fileStats.modifiedTime);
}
} catch (error) {
console.error('Error checking file stats:', error);
this.options.onError(error as Error);
}
},
500,
); // 500ms debounce
});
this.isWatching = true;
console.log('👀 Started watching file:', this.options.filePath);
} catch (error) {
console.error('Failed to start file watcher:', error);
this.options.onError(error as Error);
throw error;
}
}
/**
* Stop watching the file
*/
stop(): void {
if (!this.isWatching) {
return;
}
if (this.watchHandle && typeof this.watchHandle === 'function') {
(this.watchHandle as () => void)();
}
this.watchHandle = null;
this.isWatching = false;
this.debouncer.cancelAll();
console.log('🛑 Stopped watching file');
}
}

View file

@ -1,257 +0,0 @@
import { SyncConfig } from '../shared/types';
import { parseMarkdownTasks } from './markdown-parser';
import { generateTaskOperations } from './sync-utils';
import { PluginFileOperations } from './helper/file-operations';
import { SyncResult } from './models/sync.model';
import { FileOperations } from './models/file-operations.model';
export class SyncCoordinator {
private fileOps: FileOperations;
private config: SyncConfig;
private lastWriteTime: number = 0;
private readonly WRITE_SETTLE_TIME = 1000; // 1 second to consider our own writes
constructor(config: SyncConfig) {
this.config = config;
this.fileOps = new PluginFileOperations(PluginAPI);
}
/**
* Check if a file change was caused by our own write
*/
isOwnWrite(fileModifiedTime: number): boolean {
return fileModifiedTime - this.lastWriteTime < this.WRITE_SETTLE_TIME;
}
/**
* Sync from markdown file to Super Productivity
*/
async syncFileToSP(): Promise<SyncResult> {
try {
console.log('📥 Syncing: Markdown → SuperProductivity');
// Read and parse markdown file
const content = await this.fileOps.readFile(this.config.filePath);
const { tasks: mdTasks, errors } = parseMarkdownTasks(content);
if (errors.length > 0) {
console.warn('⚠️ Markdown parsing errors:', errors);
}
// Get current SP state
const [spTasks] = await Promise.all([
PluginAPI.getTasks(),
PluginAPI.getAllProjects(),
]);
// Generate operations to sync MD -> SP
const operations = generateTaskOperations(
mdTasks,
spTasks.filter(
(t: unknown) =>
(t as { projectId: string }).projectId === this.config.projectId,
),
this.config.projectId,
);
if (operations.length === 0) {
console.log('✅ No changes needed - MD and SP are in sync');
return { success: true, taskCount: mdTasks.length, type: 'file-to-sp' };
}
// Apply operations to SP
await PluginAPI.batchUpdateForProject({
projectId: this.config.projectId,
operations,
});
console.log(`✅ Applied ${operations.length} operations to SP`);
return {
success: true,
taskCount: mdTasks.length,
type: 'file-to-sp',
};
} catch (error) {
console.error('❌ Error syncing file to SP:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
type: 'file-to-sp',
};
}
}
/**
* Sync from Super Productivity to markdown file
*/
async syncSPToFile(): Promise<SyncResult> {
try {
console.log('📤 Syncing: SuperProductivity → Markdown');
// Get current SP state
const [tasks, projects] = await Promise.all([
PluginAPI.getTasks(),
PluginAPI.getAllProjects(),
]);
const project = projects.find(
(p: unknown) => (p as { id: string }).id === this.config.projectId,
);
if (!project) {
throw new Error(`Project ${this.config.projectId} not found`);
}
// Filter tasks for this project
const projectTasks = tasks.filter(
(t: unknown) => (t as { projectId: string }).projectId === this.config.projectId,
);
// Convert to markdown content
const content = this.generateMarkdownContent(projectTasks, project);
// Write to file
await this.fileOps.writeFile(this.config.filePath, content);
this.lastWriteTime = Date.now();
console.log(`✅ Wrote ${projectTasks.length} tasks to markdown`);
return {
success: true,
taskCount: projectTasks.length,
type: 'sp-to-file',
};
} catch (error) {
console.error('❌ Error syncing SP to file:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
type: 'sp-to-file',
};
}
}
/**
* Check integrity between SP and markdown file
*/
async checkIntegrity(): Promise<SyncResult> {
try {
console.log('🔍 Checking integrity between SP and markdown');
// Read current file
const content = await this.fileOps.readFile(this.config.filePath);
const { tasks: mdTasks } = parseMarkdownTasks(content);
// Get SP tasks
const spTasks = await PluginAPI.getTasks();
const projectTasks = spTasks.filter((t: unknown) => {
const task = t as { projectId: string; parentId?: string };
return task.projectId === this.config.projectId && !task.parentId;
});
// Compare counts first
const mdRootTasks = mdTasks.filter((t) => !t.isSubtask);
if (mdRootTasks.length !== projectTasks.length) {
const error = `Task count mismatch: MD has ${mdRootTasks.length}, SP has ${projectTasks.length}`;
console.error('❌ ' + error);
return { success: false, error, type: 'integrity-check' };
}
// Check each task
const mdTaskMap = new Map(mdRootTasks.map((t) => [t.title, t]));
const spTaskMap = new Map(
projectTasks.map((t: unknown) => {
const task = t as { title: string };
return [task.title, task];
}),
);
const differences: string[] = [];
// Check for missing/extra tasks
for (const [title] of mdTaskMap) {
if (!spTaskMap.has(title)) {
differences.push(`Task "${title}" exists in MD but not in SP`);
}
}
for (const [title] of spTaskMap) {
if (!mdTaskMap.has(title)) {
differences.push(`Task "${title}" exists in SP but not in MD`);
}
}
if (differences.length > 0) {
const error = `Integrity check failed: ${differences.join('; ')}`;
console.error('❌ ' + error);
return { success: false, error, type: 'integrity-check' };
}
console.log('✅ Integrity check passed');
return { success: true, type: 'integrity-check' };
} catch (error) {
console.error('❌ Error checking integrity:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
type: 'integrity-check',
};
}
}
/**
* Generate markdown content from SP tasks
*/
private generateMarkdownContent(tasks: unknown[], project: unknown): string {
const lines: string[] = [];
const typedProject = project as { taskIds: string[] };
// Get root tasks in order
const rootTasks = typedProject.taskIds
.map((id: string) => tasks.find((t) => (t as { id: string }).id === id))
.filter(Boolean);
for (const task of rootTasks) {
const typedTask = task as {
id?: string;
isDone: boolean;
title: string;
notes?: string;
subTaskIds?: string[];
};
// Add parent task
const parentLine =
`- [${typedTask.isDone ? 'x' : ' '}] ${typedTask.id ? `<!--${typedTask.id}-->` : ''} ${typedTask.title}`.trim();
lines.push(parentLine);
// Add parent notes
if (typedTask.notes) {
lines.push(...typedTask.notes.split('\n'));
}
// Add subtasks
if (typedTask.subTaskIds && typedTask.subTaskIds.length > 0) {
for (const subId of typedTask.subTaskIds) {
const subtask = tasks.find((t) => (t as { id: string }).id === subId);
if (subtask) {
const typedSubtask = subtask as {
id?: string;
isDone: boolean;
title: string;
notes?: string;
};
const subLine =
` - [${typedSubtask.isDone ? 'x' : ' '}] ${typedSubtask.id ? `<!--${typedSubtask.id}-->` : ''} ${typedSubtask.title}`.trim();
lines.push(subLine);
// Add subtask notes
if (typedSubtask.notes) {
lines.push(...typedSubtask.notes.split('\n').map((line) => ' ' + line));
}
}
}
}
}
return lines.join('\n');
}
}

View file

@ -1,120 +0,0 @@
/**
* Simple sync manager that handles sync operations and state
*/
import { SyncCoordinator } from './sync-coordinator';
import {
SYNC_DEBOUNCE_TIME_FOCUSED,
SYNC_DEBOUNCE_TIME_UNFOCUSED,
MIN_SYNC_INTERVAL,
LOG_PREFIX,
} from './config.const';
export class SyncManager {
private syncCoordinator: SyncCoordinator = new SyncCoordinator();
private syncDebounceTimer: unknown = null;
private lastSyncTime = 0;
private syncInProgress = false;
private pendingSyncReason: string | null = null;
private isWindowFocused = true;
setWindowFocused(focused: boolean): void {
this.isWindowFocused = focused;
}
async requestSync(reason: string): Promise<void> {
console.log(`${LOG_PREFIX.SYNC} Request sync: ${reason}`);
this.pendingSyncReason = reason;
if (this.syncInProgress) {
console.log(`${LOG_PREFIX.SYNC} Sync in progress, queuing`);
return;
}
// Clear existing timer
if (this.syncDebounceTimer) {
clearTimeout(this.syncDebounceTimer as number);
}
// Schedule sync
const debounceTime = this.isWindowFocused
? SYNC_DEBOUNCE_TIME_FOCUSED
: SYNC_DEBOUNCE_TIME_UNFOCUSED;
this.syncDebounceTimer = setTimeout(() => this.performSync(), debounceTime);
}
handleFileChange(modifiedTime: number): void {
if (this.syncCoordinator?.isOwnWrite(modifiedTime)) {
console.log(`${LOG_PREFIX.SYNC} Ignoring own write`);
this.syncCoordinator.checkIntegrity().then((result) => {
if (!result.success) {
PluginAPI.showSnack({
msg: `Integrity check failed: ${result.error}`,
type: 'ERROR',
});
}
});
return;
}
this.requestSync('File changed');
}
private async performSync(): Promise<void> {
if (!this.syncCoordinator) {
console.log(`${LOG_PREFIX.SYNC} No coordinator`);
return;
}
const timeSinceLastSync = Date.now() - this.lastSyncTime;
if (timeSinceLastSync < MIN_SYNC_INTERVAL && this.lastSyncTime > 0) {
setTimeout(() => this.performSync(), MIN_SYNC_INTERVAL - timeSinceLastSync);
return;
}
this.syncInProgress = true;
const reason = this.pendingSyncReason || 'Unknown';
this.pendingSyncReason = null;
try {
console.log(`${LOG_PREFIX.SYNC} Performing sync: ${reason}`);
if (reason.includes('File changed')) {
const result = await this.syncCoordinator.syncFileToSP();
if (!result.success) throw new Error(result.error);
} else {
const result = await this.syncCoordinator.syncSPToFile();
if (!result.success) throw new Error(result.error);
// Check integrity after write
const integrityResult = await this.syncCoordinator.checkIntegrity();
if (!integrityResult.success) {
console.error(
`${LOG_PREFIX.SYNC} Integrity check failed:`,
integrityResult.error,
);
PluginAPI.showSnack({
msg: `Integrity check failed: ${integrityResult.error}`,
type: 'ERROR',
});
}
}
this.lastSyncTime = Date.now();
console.log(`${LOG_PREFIX.SYNC} Sync completed`);
} catch (error) {
console.error(`${LOG_PREFIX.SYNC} Sync error:`, error);
PluginAPI.showSnack({
msg: `Sync error: ${error instanceof Error ? error.message : 'Unknown error'}`,
type: 'ERROR',
});
} finally {
this.syncInProgress = false;
if (this.pendingSyncReason) {
setTimeout(() => this.performSync(), 1000);
}
}
}
}

View file

@ -1,14 +0,0 @@
// Type declarations for Super Productivity Plugin API
import { PluginAPI } from '@super-productivity/plugin-api';
declare global {
const PluginAPI: PluginAPI;
interface Window {
PluginAPI: PluginAPI;
initPlugin?: () => void | Promise<void>;
// @ts-ignore
parent: Window;
}
}