Merge branch 'fix/sync-md-header'

* fix/sync-md-header:
  feat(sync-md): add support for markdown content before tasks as needed for joplin #4751
This commit is contained in:
Johannes Millan 2025-07-25 16:08:11 +02:00
commit fa5e5325df
16 changed files with 548 additions and 148 deletions

View file

@ -1,21 +1,18 @@
import {
TaskBuilder,
ParsedTaskBuilder,
MarkdownBuilder,
createMockConfig,
createMockPluginAPI,
createTaskHierarchy,
generateLargeMarkdown,
MarkdownBuilder,
measureExecutionTime,
ParsedTaskBuilder,
TaskBuilder,
} from '../test-utils';
import { parseMarkdown } from '../../sync/markdown-parser';
import { parseMarkdown, parseMarkdownWithHeader } from '../../sync/markdown-parser';
import { generateTaskOperations } from '../../sync/generate-task-operations';
import { convertTasksToMarkdown } from '../../sync/sp-to-md';
import { mdToSp } from '../../sync/md-to-sp';
import { spToMd } from '../../sync/sp-to-md';
// Mock dependencies
jest.mock('../../sync/markdown-parser');
jest.mock('../../sync/generate-task-operations');
jest.mock('../../helper/file-utils');
@ -117,44 +114,7 @@ describe('Comprehensive Sync Tests', () => {
.addTask('Add animations', { id: 'animations', indent: 4 })
.build();
// Mock parser to return appropriate structure
(parseMarkdown as jest.Mock).mockReturnValue([
new ParsedTaskBuilder()
.withId('overview')
.withTitle('Project Overview')
.withNotes('This project contains multiple features')
.build(),
new ParsedTaskBuilder().withId('feature1').withTitle('Feature 1').build(),
new ParsedTaskBuilder()
.withId('api')
.withTitle('Implement API')
.withParentId('feature1')
.withIsSubtask(true)
.build(),
new ParsedTaskBuilder()
.withId('tests')
.withTitle('Write tests')
.withParentId('feature1')
.withIsSubtask(true)
.withCompleted(true)
.withNotes('Unit and integration tests')
.build(),
new ParsedTaskBuilder().withId('feature2').withTitle('Feature 2').build(),
new ParsedTaskBuilder()
.withId('ui')
.withTitle('Design UI')
.withParentId('feature2')
.withIsSubtask(true)
.build(),
new ParsedTaskBuilder()
.withId('animations')
.withTitle('Add animations')
.withParentId('ui')
.withIsSubtask(true)
.build(),
]);
// Mock operations
// Mock operations instead of parser since we're testing integration
(generateTaskOperations as jest.Mock).mockReturnValue([
{
type: 'create',
@ -165,7 +125,6 @@ describe('Comprehensive Sync Tests', () => {
await mdToSp(markdown, 'test-project');
expect(parseMarkdown).toHaveBeenCalledWith(markdown);
expect(generateTaskOperations).toHaveBeenCalled();
expect(mockPluginAPI.batchUpdateForProject).toHaveBeenCalled();
});
@ -175,20 +134,12 @@ describe('Comprehensive Sync Tests', () => {
it('should handle large markdown efficiently', async () => {
const largeMarkdown = generateLargeMarkdown(1000);
// Mock parseMarkdown to simulate parsing
(parseMarkdown as jest.Mock).mockImplementation((content) => {
// Simulate parsing time
const lines = content.split('\n');
return lines
.filter((line: string) => line.match(/^[\s]*- \[/))
.map((line: string, index: number) =>
new ParsedTaskBuilder().withLine(index).withTitle(`Task ${index}`).build(),
);
});
// Test actual parsing performance without mocking
const time = await measureExecutionTime(() => {
const result = parseMarkdown(largeMarkdown);
expect(result.length).toBeGreaterThan(900); // Some have notes
// The parser only returns tasks at depth 0 and 1 (parents and subtasks)
// Tasks with indent >= 4 (depth >= 2) are converted to notes
expect(result.length).toBeGreaterThan(200); // Should have many tasks
});
expect(time).toBeLessThan(100); // Should parse in under 100ms
@ -201,9 +152,9 @@ describe('Comprehensive Sync Tests', () => {
});
// Convert to markdown
const markdownTime = await measureExecutionTime(() =>
convertTasksToMarkdown(tasks),
);
const markdownTime = await measureExecutionTime(() => {
convertTasksToMarkdown(tasks);
});
expect(markdownTime).toBeLessThan(50);
@ -216,9 +167,9 @@ describe('Comprehensive Sync Tests', () => {
})),
);
const opsTime = await measureExecutionTime(() =>
generateTaskOperations([], tasks, 'test-project'),
);
const opsTime = await measureExecutionTime(() => {
generateTaskOperations([], tasks, 'test-project');
});
expect(opsTime).toBeLessThan(50);
});
@ -323,20 +274,9 @@ describe('Comprehensive Sync Tests', () => {
// First cycle: SP to MD
const markdown1 = convertTasksToMarkdown(initialTasks);
// Mock parse to return equivalent structure
(parseMarkdown as jest.Mock).mockReturnValue(
initialTasks.map((t) =>
new ParsedTaskBuilder()
.withId(t.id)
.withTitle(t.title)
.withCompleted(t.isDone)
.withParentId(t.parentId)
.build(),
),
);
// Second cycle: MD to SP
const parsed = parseMarkdown(markdown1);
// Second cycle: MD to SP - parse the actual markdown
const result = parseMarkdownWithHeader(markdown1);
const parsed = result.tasks;
// Verify consistency
expect(parsed).toHaveLength(initialTasks.length);

View file

@ -1,6 +1,5 @@
import { parseMarkdown, parseMarkdownWithErrors } from '../../sync/markdown-parser';
import { generateTaskOperations } from '../../sync/generate-task-operations';
import { Task } from '@super-productivity/plugin-api';
describe('Error Scenarios and Boundary Conditions', () => {
describe('Markdown Parser - Error Scenarios', () => {
@ -313,7 +312,7 @@ describe('Error Scenarios and Boundary Conditions', () => {
maliciousPatterns.forEach((pattern) => {
const start = Date.now();
const tasks = parseMarkdown(pattern);
parseMarkdown(pattern);
const duration = Date.now() - start;
// Should not hang or take excessive time

View file

@ -1,9 +1,8 @@
import { startFileWatcher, stopFileWatcher } from '../../sync/file-watcher';
import * as path from 'path';
// Set up Node.js environment for tests
if (typeof setImmediate === 'undefined') {
(global as any).setImmediate = (fn: Function) => setTimeout(fn, 0);
(global as any).setImmediate = (fn: () => void) => setTimeout(fn, 0);
}
// Mock PluginAPI globally

View file

@ -1,5 +1,5 @@
import { mdToSp } from '../../sync/md-to-sp';
import { parseMarkdown } from '../../sync/markdown-parser';
import { parseMarkdownWithHeader } from '../../sync/markdown-parser';
import { generateTaskOperations } from '../../sync/generate-task-operations';
import { Task } from '@super-productivity/plugin-api';
@ -12,6 +12,19 @@ const mockPluginAPI = {
getTasks: jest.fn(),
batchUpdateForProject: jest.fn(),
getAllProjects: jest.fn(),
log: {
critical: jest.fn(),
err: jest.fn(),
error: jest.fn(),
log: jest.fn(),
normal: jest.fn(),
info: jest.fn(),
verbose: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
persistDataSynced: jest.fn(),
loadSyncedData: jest.fn(),
};
(global as any).PluginAPI = mockPluginAPI;
@ -71,12 +84,15 @@ describe('MD to SP Sync - Complete Tests', () => {
},
];
(parseMarkdown as jest.Mock).mockReturnValue(mockParsedTasks);
(parseMarkdownWithHeader as jest.Mock).mockReturnValue({
tasks: mockParsedTasks,
errors: [],
});
(generateTaskOperations as jest.Mock).mockReturnValue(mockOperations);
await mdToSp(mockMarkdownContent, mockProjectId);
expect(parseMarkdown).toHaveBeenCalledWith(mockMarkdownContent);
expect(parseMarkdownWithHeader).toHaveBeenCalledWith(mockMarkdownContent);
expect(mockPluginAPI.getTasks).toHaveBeenCalledWith();
expect(generateTaskOperations).toHaveBeenCalledWith(
mockParsedTasks,
@ -90,12 +106,12 @@ describe('MD to SP Sync - Complete Tests', () => {
});
it('should handle empty markdown content', async () => {
(parseMarkdown as jest.Mock).mockReturnValue([]);
(parseMarkdownWithHeader as jest.Mock).mockReturnValue({ tasks: [], errors: [] });
(generateTaskOperations as jest.Mock).mockReturnValue([]);
await mdToSp('', mockProjectId);
expect(parseMarkdown).toHaveBeenCalledWith('');
expect(parseMarkdownWithHeader).toHaveBeenCalledWith('');
// batchUpdateForProject won't be called with empty operations
expect(mockPluginAPI.batchUpdateForProject).not.toHaveBeenCalled();
});
@ -103,12 +119,12 @@ describe('MD to SP Sync - Complete Tests', () => {
it('should handle markdown with only whitespace', async () => {
const whitespaceContent = ' \n\n \t ';
(parseMarkdown as jest.Mock).mockReturnValue([]);
(parseMarkdownWithHeader as jest.Mock).mockReturnValue({ tasks: [], errors: [] });
(generateTaskOperations as jest.Mock).mockReturnValue([]);
await mdToSp(whitespaceContent, mockProjectId);
expect(parseMarkdown).toHaveBeenCalledWith(whitespaceContent);
expect(parseMarkdownWithHeader).toHaveBeenCalledWith(whitespaceContent);
// batchUpdateForProject won't be called with empty operations
expect(mockPluginAPI.batchUpdateForProject).not.toHaveBeenCalled();
});
@ -150,7 +166,10 @@ describe('MD to SP Sync - Complete Tests', () => {
];
mockPluginAPI.getTasks.mockResolvedValue(existingTasks);
(parseMarkdown as jest.Mock).mockReturnValue(mockParsedTasks);
(parseMarkdownWithHeader as jest.Mock).mockReturnValue({
tasks: mockParsedTasks,
errors: [],
});
(generateTaskOperations as jest.Mock).mockReturnValue(mockOperations);
await mdToSp(mockMarkdownContent, mockProjectId);
@ -190,7 +209,7 @@ describe('MD to SP Sync - Complete Tests', () => {
it('should handle parseMarkdown error', async () => {
const error = new Error('Parse error');
(parseMarkdown as jest.Mock).mockImplementation(() => {
(parseMarkdownWithHeader as jest.Mock).mockImplementation(() => {
throw error;
});
@ -211,7 +230,10 @@ describe('MD to SP Sync - Complete Tests', () => {
},
];
(parseMarkdown as jest.Mock).mockReturnValue(parsedTasks);
(parseMarkdownWithHeader as jest.Mock).mockReturnValue({
tasks: parsedTasks,
errors: [],
});
(generateTaskOperations as jest.Mock).mockReturnValue([
{ type: 'create', data: { title: 'test' } },
]);
@ -238,7 +260,10 @@ describe('MD to SP Sync - Complete Tests', () => {
},
}));
(parseMarkdown as jest.Mock).mockReturnValue(largeTasks);
(parseMarkdownWithHeader as jest.Mock).mockReturnValue({
tasks: largeTasks,
errors: [],
});
(generateTaskOperations as jest.Mock).mockReturnValue(largeOperations);
await mdToSp('large content', mockProjectId);
@ -277,7 +302,10 @@ describe('MD to SP Sync - Complete Tests', () => {
},
];
(parseMarkdown as jest.Mock).mockReturnValue(orderedTasks);
(parseMarkdownWithHeader as jest.Mock).mockReturnValue({
tasks: orderedTasks,
errors: [],
});
(generateTaskOperations as jest.Mock).mockImplementation((tasks) => {
// Verify tasks are passed in the correct order
expect(tasks).toEqual(orderedTasks);
@ -314,7 +342,10 @@ describe('MD to SP Sync - Complete Tests', () => {
},
];
(parseMarkdown as jest.Mock).mockReturnValue(specialTasks);
(parseMarkdownWithHeader as jest.Mock).mockReturnValue({
tasks: specialTasks,
errors: [],
});
(generateTaskOperations as jest.Mock).mockReturnValue([]);
await mdToSp(mockMarkdownContent, mockProjectId);
@ -352,7 +383,10 @@ describe('MD to SP Sync - Complete Tests', () => {
},
];
(parseMarkdown as jest.Mock).mockReturnValue(nestedTasks);
(parseMarkdownWithHeader as jest.Mock).mockReturnValue({
tasks: nestedTasks,
errors: [],
});
(generateTaskOperations as jest.Mock).mockReturnValue([]);
await mdToSp(mockMarkdownContent, mockProjectId);
@ -361,7 +395,7 @@ describe('MD to SP Sync - Complete Tests', () => {
});
it('should skip sync when no operations are needed', async () => {
(parseMarkdown as jest.Mock).mockReturnValue([]);
(parseMarkdownWithHeader as jest.Mock).mockReturnValue({ tasks: [], errors: [] });
(generateTaskOperations as jest.Mock).mockReturnValue([]);
await mdToSp(mockMarkdownContent, mockProjectId);

View file

@ -5,18 +5,43 @@ import { Task } from '@super-productivity/plugin-api';
// Mock dependencies
jest.mock('../../helper/file-utils');
jest.mock('../../../shared/logger', () => ({
log: {
critical: jest.fn(),
err: jest.fn(),
error: jest.fn(),
log: jest.fn(),
normal: jest.fn(),
info: jest.fn(),
verbose: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
}));
// Mock PluginAPI
global.PluginAPI = {
(global as any).PluginAPI = {
getTasks: jest.fn(),
getAllProjects: jest.fn(),
log: {
critical: jest.fn(),
err: jest.fn(),
error: jest.fn(),
log: jest.fn(),
normal: jest.fn(),
info: jest.fn(),
verbose: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
persistDataSynced: jest.fn(),
loadSyncedData: jest.fn(),
} as any;
describe('spToMd', () => {
const mockConfig: LocalUserCfg = {
filePath: '/test/tasks.md',
projectId: 'test-project',
enabled: true,
};
beforeEach(() => {
@ -66,8 +91,10 @@ describe('spToMd', () => {
},
];
(global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
(global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue(mockProjects);
((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue(
mockProjects,
);
await spToMd(mockConfig);
@ -131,8 +158,10 @@ describe('spToMd', () => {
},
];
(global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
(global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue(mockProjects);
((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue(
mockProjects,
);
await spToMd(mockConfig);
@ -185,8 +214,10 @@ describe('spToMd', () => {
},
];
(global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
(global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue(mockProjects);
((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue(
mockProjects,
);
await spToMd(mockConfig);

View file

@ -10,6 +10,19 @@ jest.mock('../../sync/sp-to-md');
jest.mock('../../sync/md-to-sp');
jest.mock('../../helper/file-utils');
jest.mock('../../sync/verify-sync');
jest.mock('../../../shared/logger', () => ({
log: {
critical: jest.fn(),
err: jest.fn(),
error: jest.fn(),
log: jest.fn(),
normal: jest.fn(),
info: jest.fn(),
verbose: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
}));
// Import after mocking
import { spToMd } from '../../sync/sp-to-md';
@ -21,6 +34,23 @@ import {
} from '../../helper/file-utils';
import { verifySyncState, logSyncVerification } from '../../sync/verify-sync';
// Mock PluginAPI with log methods
(global as any).PluginAPI = {
log: {
critical: jest.fn(),
err: jest.fn(),
error: jest.fn(),
log: jest.fn(),
normal: jest.fn(),
info: jest.fn(),
verbose: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
persistDataSynced: jest.fn(),
loadSyncedData: jest.fn(),
};
// Mock console to reduce noise
beforeAll(() => {
jest.spyOn(console, 'log').mockImplementation(() => {});
@ -55,8 +85,8 @@ describe('Sync Manager Debounce Behavior', () => {
return Promise.resolve();
});
(mdToSp as jest.Mock).mockImplementation(() => {
// Return a resolved promise
return Promise.resolve();
// Return a resolved promise with the expected structure
return Promise.resolve({ header: undefined });
});
(verifySyncState as jest.Mock).mockResolvedValue({ isInSync: true, differences: [] });
(logSyncVerification as jest.Mock).mockReturnValue(undefined);

View file

@ -5,7 +5,6 @@ import * as fileUtils from '../../helper/file-utils';
import { spToMd } from '../../sync/sp-to-md';
import { mdToSp } from '../../sync/md-to-sp';
import { verifySyncState, logSyncVerification } from '../../sync/verify-sync';
import { PluginHooks } from '@super-productivity/plugin-api';
// Mock dependencies
jest.mock('../../sync/file-watcher');
@ -13,6 +12,38 @@ jest.mock('../../helper/file-utils');
jest.mock('../../sync/sp-to-md');
jest.mock('../../sync/md-to-sp');
jest.mock('../../sync/verify-sync');
jest.mock('../../../shared/logger', () => ({
log: {
critical: jest.fn(),
err: jest.fn(),
error: jest.fn(),
log: jest.fn(),
normal: jest.fn(),
info: jest.fn(),
verbose: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
}));
// Mock PluginAPI
(global as any).PluginAPI = {
registerHook: jest.fn(),
onWindowFocusChange: jest.fn(),
log: {
critical: jest.fn(),
err: jest.fn(),
error: jest.fn(),
log: jest.fn(),
normal: jest.fn(),
info: jest.fn(),
verbose: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
persistDataSynced: jest.fn(),
loadSyncedData: jest.fn(),
};
describe('Sync Manager - Edge Cases', () => {
const mockConfig: LocalUserCfg = {

View file

@ -7,7 +7,7 @@ import { Task } from '@super-productivity/plugin-api';
jest.mock('../../helper/file-utils');
// Mock PluginAPI
global.PluginAPI = {
(global as any).PluginAPI = {
getTasks: jest.fn(),
getAllProjects: jest.fn(),
} as any;
@ -20,7 +20,6 @@ describe('verifySyncState', () => {
const mockConfig: LocalUserCfg = {
filePath: '/test/tasks.md',
projectId: 'test-project',
enabled: true,
};
beforeEach(() => {
@ -55,8 +54,8 @@ describe('verifySyncState', () => {
const mockMarkdown = `- [ ] <!--task1--> Task 1
- [x] <!--task2--> Task 2`;
(global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
(global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]);
((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]);
(fileUtils.readTasksFile as jest.Mock).mockResolvedValue(mockMarkdown);
const result = await verifySyncState(mockConfig);
@ -85,8 +84,8 @@ describe('verifySyncState', () => {
const mockMarkdown = `- [ ] <!--task1--> Task 1`;
(global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
(global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]);
((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]);
(fileUtils.readTasksFile as jest.Mock).mockResolvedValue(mockMarkdown);
const result = await verifySyncState(mockConfig);
@ -114,8 +113,8 @@ describe('verifySyncState', () => {
const mockMarkdown = `- [ ] <!--task1--> Task 1
- [ ] <!--task2--> Task 2`;
(global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
(global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]);
((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]);
(fileUtils.readTasksFile as jest.Mock).mockResolvedValue(mockMarkdown);
const result = await verifySyncState(mockConfig);
@ -142,8 +141,8 @@ describe('verifySyncState', () => {
const mockMarkdown = `- [ ] <!--task1--> Task 1 MD`;
(global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
(global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]);
((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]);
(fileUtils.readTasksFile as jest.Mock).mockResolvedValue(mockMarkdown);
const result = await verifySyncState(mockConfig);
@ -171,8 +170,8 @@ describe('verifySyncState', () => {
const mockMarkdown = `- [ ] <!--task1--> Task 1`;
(global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
(global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]);
((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]);
(fileUtils.readTasksFile as jest.Mock).mockResolvedValue(mockMarkdown);
const result = await verifySyncState(mockConfig);
@ -216,8 +215,10 @@ describe('verifySyncState', () => {
const mockMarkdown = `- [ ] <!--task2--> Task 2
- [ ] <!--task1--> Task 1`;
(global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
(global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue(mockProjects);
((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue(
mockProjects,
);
(fileUtils.readTasksFile as jest.Mock).mockResolvedValue(mockMarkdown);
const result = await verifySyncState(mockConfig);
@ -261,8 +262,8 @@ describe('verifySyncState', () => {
- [ ] <!--child2--> Child 2
- [ ] <!--child1--> Child 1`;
(global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
(global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]);
((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]);
(fileUtils.readTasksFile as jest.Mock).mockResolvedValue(mockMarkdown);
const result = await verifySyncState(mockConfig);
@ -288,8 +289,8 @@ describe('verifySyncState', () => {
} as Task,
];
(global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
(global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]);
((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]);
(fileUtils.readTasksFile as jest.Mock).mockResolvedValue('');
const result = await verifySyncState(mockConfig);
@ -323,8 +324,8 @@ describe('verifySyncState', () => {
const mockMarkdown = `- [ ] <!--task1--> Task 1
- [ ] <!--task2--> Task 2`;
(global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
(global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]);
((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]);
(fileUtils.readTasksFile as jest.Mock).mockResolvedValue(mockMarkdown);
const result = await verifySyncState(mockConfig);
@ -348,8 +349,8 @@ describe('verifySyncState', () => {
const mockMarkdown = `- [ ] <!--task1--> Task 1
MD notes`;
(global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
(global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]);
((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks);
((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]);
(fileUtils.readTasksFile as jest.Mock).mockResolvedValue(mockMarkdown);
const result = await verifySyncState(mockConfig);

View file

@ -1,5 +1,5 @@
import { Task } from '@super-productivity/plugin-api';
import { ParsedTask } from '../sync/types';
import { ParsedTask } from '../sync/markdown-parser';
import { LocalUserCfg } from '../local-config';
/**
@ -167,7 +167,7 @@ export const createMockConfig = (overrides?: Partial<LocalUserCfg>): LocalUserCf
...overrides,
});
export const createMockPluginAPI = () => {
export const createMockPluginAPI = (): any => {
const mockAPI = {
registerHook: jest.fn(),
onWindowFocusChange: jest.fn(),
@ -176,6 +176,19 @@ export const createMockPluginAPI = () => {
.fn()
.mockResolvedValue([{ id: 'test-project', title: 'Test Project' }]),
batchUpdateForProject: jest.fn().mockResolvedValue(undefined),
log: {
critical: jest.fn(),
err: jest.fn(),
error: jest.fn(),
log: jest.fn(),
normal: jest.fn(),
info: jest.fn(),
verbose: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
persistDataSynced: jest.fn(),
loadSyncedData: jest.fn(),
};
(global as any).PluginAPI = mockAPI;
@ -242,7 +255,8 @@ export const generateLargeMarkdown = (taskCount: number): string => {
return builder.build();
};
export const waitForAsync = () => new Promise((resolve) => setImmediate(resolve));
export const waitForAsync = (): Promise<void> =>
new Promise((resolve) => setImmediate(resolve));
export const measureExecutionTime = async (
fn: () => void | Promise<void>,
@ -256,7 +270,7 @@ export const measureExecutionTime = async (
* Custom Jest matchers
*/
export const customMatchers = {
toBeValidTask(received: any) {
toBeValidTask: (received: any) => {
const pass =
received &&
typeof received.id === 'string' &&
@ -273,7 +287,7 @@ export const customMatchers = {
};
},
toBeValidParsedTask(received: any) {
toBeValidParsedTask: (received: any) => {
const pass =
received &&
typeof received.line === 'number' &&

View file

@ -0,0 +1,180 @@
import { mdToSp } from './md-to-sp';
import { spToMd } from './sp-to-md';
import { LocalUserCfg } from '../local-config';
import { Task } from '@super-productivity/plugin-api';
// Import mocked modules
import {
ensureDirectoryExists,
writeTasksFile,
readTasksFile,
} from '../helper/file-utils';
// Mock dependencies
jest.mock('../helper/file-utils');
// Mock the PluginAPI
describe('Header Preservation Integration', () => {
const mockConfig: LocalUserCfg = {
filePath: '/test/tasks.md',
projectId: 'test-project',
};
let mockTasks: Task[] = [];
let mockProjects: any[] = [];
let mockSyncedData: Record<string, any> = {};
beforeEach(() => {
jest.clearAllMocks();
mockTasks = [];
mockProjects = [
{
id: 'test-project',
title: 'Test Project',
taskIds: [],
},
];
mockSyncedData = {};
// Setup PluginAPI mock
(global as any).PluginAPI = {
getTasks: jest.fn().mockResolvedValue(mockTasks),
getAllProjects: jest.fn().mockResolvedValue(mockProjects),
batchUpdateForProject: jest.fn().mockImplementation(({ operations }) => {
// Simulate batch operations
operations.forEach((op: any) => {
if (op.type === 'ADD_TASK') {
mockTasks.push(op.task);
} else if (op.type === 'UPDATE_TASK') {
const index = mockTasks.findIndex((t) => t.id === op.task.id);
if (index >= 0) {
mockTasks[index] = { ...mockTasks[index], ...op.task };
}
}
});
return Promise.resolve();
}),
persistDataSynced: jest.fn().mockImplementation((data) => {
Object.assign(mockSyncedData, data);
return Promise.resolve();
}),
loadSyncedData: jest.fn().mockResolvedValue(mockSyncedData),
};
});
it('should preserve header content through full sync cycle', async () => {
const markdownWithHeader = `# Project Tasks
This is my project description.
## Important Notes
- Remember to check dependencies
- Update documentation
- [ ] <!--task-1--> Task 1
- [ ] <!--task-2--> Task 2
- [ ] <!--subtask-1--> Subtask 2.1`;
// Step 1: Parse markdown and sync to SP
await mdToSp(markdownWithHeader, mockConfig.projectId);
// Step 2: Mock the file read to return the original content with header
(readTasksFile as jest.Mock).mockResolvedValue(markdownWithHeader);
// Mock file operations
(ensureDirectoryExists as jest.Mock).mockResolvedValue(undefined);
(writeTasksFile as jest.Mock).mockResolvedValue(undefined);
// Setup mock tasks that would have been created from mdToSp
mockTasks = [
{
id: 'task-1',
title: 'Task 1',
isDone: false,
projectId: 'test-project',
parentId: null,
subTaskIds: [],
},
{
id: 'task-2',
title: 'Task 2',
isDone: false,
projectId: 'test-project',
parentId: null,
subTaskIds: ['subtask-1'],
},
{
id: 'subtask-1',
title: 'Subtask 2.1',
isDone: false,
projectId: 'test-project',
parentId: 'task-2',
subTaskIds: [],
},
] as unknown as Task[];
// Update the mock to return our tasks
(global as any).PluginAPI.getTasks.mockResolvedValue(mockTasks);
// Update project to have the task IDs
mockProjects[0].taskIds = ['task-1', 'task-2'];
// Step 3: Run spToMd and verify header is preserved
await spToMd(mockConfig);
// Verify the written content includes the header
expect(writeTasksFile).toHaveBeenCalled();
const writtenContent = (writeTasksFile as jest.Mock).mock.calls[0][1];
// The content should start with the header
expect(writtenContent).toMatch(/^# Project Tasks/);
expect(writtenContent).toContain('This is my project description.');
expect(writtenContent).toContain('## Important Notes');
expect(writtenContent).toContain('- Remember to check dependencies');
expect(writtenContent).toContain('- Update documentation');
// And should include the tasks
expect(writtenContent).toContain('- [ ] <!--task-1--> Task 1');
expect(writtenContent).toContain('- [ ] <!--task-2--> Task 2');
expect(writtenContent).toContain(' - [ ] <!--subtask-1--> Subtask 2.1');
});
it('should handle empty header', async () => {
const markdownNoHeader = `- [ ] Task 1
- [ ] Task 2`;
// Mock file operations
(readTasksFile as jest.Mock).mockResolvedValue(markdownNoHeader);
(ensureDirectoryExists as jest.Mock).mockResolvedValue(undefined);
(writeTasksFile as jest.Mock).mockResolvedValue(undefined);
// Setup mock tasks
mockTasks = [
{
id: 'task-1',
title: 'Task 1',
isDone: false,
projectId: 'test-project',
parentId: null,
subTaskIds: [],
},
{
id: 'task-2',
title: 'Task 2',
isDone: false,
projectId: 'test-project',
parentId: null,
subTaskIds: [],
},
] as unknown as Task[];
(global as any).PluginAPI.getTasks.mockResolvedValue(mockTasks);
// Run spToMd
await spToMd(mockConfig);
// Verify no header was added
const writtenContent = (writeTasksFile as jest.Mock).mock.calls[0][1];
expect(writtenContent).not.toMatch(/^#/);
expect(writtenContent).toMatch(/^- \[ \] <!--task-1--> Task 1/);
});
});

View file

@ -1,4 +1,7 @@
import { parseMarkdownWithErrors as parseTasks } from './markdown-parser';
import {
parseMarkdownWithErrors as parseTasks,
parseMarkdownWithHeader,
} from './markdown-parser';
describe('markdown-parser', () => {
describe('parseTasks', () => {
@ -238,4 +241,94 @@ describe('markdown-parser', () => {
expect(result.tasks[4].notes).toBe('Note line 1\nNote line 2');
});
});
describe('parseMarkdownWithHeader', () => {
it('should extract header content before first task', () => {
const markdown = `# My Tasks
This is a description of my tasks.
Some more text here.
- [ ] Task 1
- [ ] Task 2`;
const result = parseMarkdownWithHeader(markdown);
expect(result.header).toBe(
'# My Tasks\nThis is a description of my tasks.\n\nSome more text here.\n',
);
expect(result.tasks.length).toBe(2);
expect(result.tasks[0].title).toBe('Task 1');
expect(result.tasks[1].title).toBe('Task 2');
});
it('should handle markdown with no header', () => {
const markdown = `- [ ] Task 1
- [ ] Task 2`;
const result = parseMarkdownWithHeader(markdown);
expect(result.header).toBeUndefined();
expect(result.tasks.length).toBe(2);
});
it('should handle markdown with only header and no tasks', () => {
const markdown = `# My Header
This is just a header with no tasks.`;
const result = parseMarkdownWithHeader(markdown);
expect(result.header).toBe('# My Header\nThis is just a header with no tasks.');
expect(result.tasks.length).toBe(0);
});
it('should preserve complex header with metadata', () => {
const markdown = `---
title: My Project Tasks
date: 2024-01-01
tags: [project, tasks]
---
# Project Overview
This document contains all the tasks for my project.
## Important Notes
- Remember to check dependencies
- Update documentation
- [ ] Task 1
- [ ] Task 2`;
const result = parseMarkdownWithHeader(markdown);
expect(result.header).toBe(`---
title: My Project Tasks
date: 2024-01-01
tags: [project, tasks]
---
# Project Overview
This document contains all the tasks for my project.
## Important Notes
- Remember to check dependencies
- Update documentation
`);
expect(result.tasks.length).toBe(2);
});
it('should handle empty lines before first task', () => {
const markdown = `# Header
- [ ] Task 1`;
const result = parseMarkdownWithHeader(markdown);
expect(result.header).toBe('# Header\n\n');
expect(result.tasks.length).toBe(1);
});
});
});

View file

@ -16,6 +16,7 @@ export interface ParsedTask {
export interface TaskParseResult {
tasks: ParsedTask[];
errors: string[];
header?: string;
}
/**
@ -61,6 +62,14 @@ export const parseMarkdown = (content: string): ParsedTask[] => {
return result.tasks;
};
/**
* Parse markdown content and return header content
* Returns everything before the first task
*/
export const parseMarkdownWithHeader = (content: string): TaskParseResult => {
return parseMarkdownWithErrors(content);
};
/**
* Parse markdown content into task objects with error collection
* Handles tasks and subtasks (first two levels) and converts deeper levels to notes
@ -80,6 +89,8 @@ export const parseMarkdownWithErrors = (content: string): TaskParseResult => {
console.log(`[sync-md] Detected indent size: ${detectedIndentSize} spaces`);
let currentTaskIndex = -1;
let firstTaskLineIndex = -1;
let header: string | undefined;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
@ -88,6 +99,15 @@ export const parseMarkdownWithErrors = (content: string): TaskParseResult => {
const taskMatch = line.match(/^(\s*)- \[([ x])\]\s*(?:<!--([^>]+)-->\s*)?(.*)$/);
if (taskMatch) {
// Mark the first task line if not already marked
if (firstTaskLineIndex === -1) {
firstTaskLineIndex = i;
// Extract header content (everything before the first task)
if (i > 0) {
header = lines.slice(0, i).join('\n');
}
}
const [, indent, completed, id, title] = taskMatch;
const indentLevel = indent.length;
const depth = indentLevel === 0 ? 0 : Math.floor(indentLevel / detectedIndentSize);
@ -200,7 +220,12 @@ export const parseMarkdownWithErrors = (content: string): TaskParseResult => {
}
}
return { tasks, errors };
// If no tasks were found, the entire content is considered header
if (firstTaskLineIndex === -1 && lines.length > 0) {
header = content;
}
return { tasks, errors, header };
};
/**

View file

@ -1,4 +1,4 @@
import { parseMarkdown } from './markdown-parser';
import { parseMarkdownWithHeader } from './markdown-parser';
import { generateTaskOperations } from './generate-task-operations';
// import { Task } from '@super-productivity/plugin-api';
@ -10,7 +10,9 @@ export const mdToSp = async (
markdownContent: string,
projectId: string,
): Promise<void> => {
const parsedTasks = parseMarkdown(markdownContent);
// Use parseMarkdownWithHeader to get header, but handle backward compatibility
const parseResult = parseMarkdownWithHeader(markdownContent);
const parsedTasks = parseResult.tasks;
// Get current state
const currentTasks = await PluginAPI.getTasks();

View file

@ -1,6 +1,11 @@
import { Task } from '@super-productivity/plugin-api';
import { writeTasksFile, ensureDirectoryExists } from '../helper/file-utils';
import {
writeTasksFile,
ensureDirectoryExists,
readTasksFile,
} from '../helper/file-utils';
import { LocalUserCfg } from '../local-config';
import { parseMarkdownWithHeader } from './markdown-parser';
/**
* Replicate Super Productivity tasks to markdown file
@ -44,8 +49,27 @@ export const spToMd = async (config: LocalUserCfg): Promise<void> => {
// Convert project tasks to markdown
const markdown = convertTasksToMarkdown(orderedTasks);
// Get existing header from file
let header = '';
try {
const existingContent = await readTasksFile(config.filePath);
if (existingContent) {
const parsed = parseMarkdownWithHeader(existingContent);
header = parsed.header || '';
}
} catch (error) {
// File doesn't exist yet, no header to preserve
}
// Combine header and tasks
let finalContent = markdown;
if (header) {
// Add newline between header and tasks if both exist
finalContent = header + (markdown ? '\n' + markdown : '');
}
// Write to file
await writeTasksFile(config.filePath, markdown);
await writeTasksFile(config.filePath, finalContent);
};
/**

View file

@ -2,11 +2,7 @@ import { startFileWatcher, stopFileWatcher } from './file-watcher';
import { spToMd } from './sp-to-md';
import { mdToSp } from './md-to-sp';
import { getFileStats, readTasksFile } from '../helper/file-utils';
import {
SYNC_DEBOUNCE_MS,
SYNC_DEBOUNCE_MS_UNFOCUSED,
SYNC_DEBOUNCE_MS_MD_TO_SP,
} from '../config.const';
import { SYNC_DEBOUNCE_MS, SYNC_DEBOUNCE_MS_MD_TO_SP } from '../config.const';
import { PluginHooks } from '@super-productivity/plugin-api';
import { LocalUserCfg } from '../local-config';
import { logSyncVerification, verifySyncState } from './verify-sync';
@ -26,7 +22,9 @@ export const initSyncManager = (config: LocalUserCfg): void => {
setupWindowFocusTracking();
// Perform initial sync
performInitialSync(config).then((r) => log.log('SyncMD initial sync', r));
performInitialSync(config)
.then(() => log.debug('SyncMD initial sync completed'))
.catch((error) => log.error('SyncMD initial sync failed', error));
// Set up file watcher for ongoing sync
startFileWatcher(config.filePath, () => {

View file

@ -134,7 +134,6 @@ export const verifySyncState = async (
}
// Check task order
const spParentTasks = spTasks.filter((t) => !t.parentId);
const mdParentTasks = mdTasks.filter((t) => !t.parentId);
// If project has taskIds, use them for ordering