mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
cleanup
This commit is contained in:
parent
4b7bdf1b14
commit
1d2e80e552
15 changed files with 10 additions and 1383 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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',
|
||||
};
|
||||
};
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue