mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-08-02 12:32:14 +00:00
- Add @super-productivity/plugin-api package with TypeScript definitions - Define core plugin interfaces, types, and manifest structure - Add plugin hooks system for event-driven architecture - Create plugin API type definitions and constants - Add documentation and development guidelines
551 lines
16 KiB
TypeScript
551 lines
16 KiB
TypeScript
import {
|
|
Task,
|
|
MarkdownTask,
|
|
SyncDirection,
|
|
SyncResult as BaseSyncResult,
|
|
SyncConflict,
|
|
} from './types';
|
|
|
|
// Tree node structure for both markdown and tasks
|
|
export interface TreeNode {
|
|
id?: string;
|
|
title: string;
|
|
isDone: boolean;
|
|
children: TreeNode[];
|
|
notes?: string;
|
|
parentId?: string | null;
|
|
level: number;
|
|
}
|
|
|
|
// Sync operation types
|
|
export interface SyncOperation {
|
|
type: 'add' | 'update' | 'delete';
|
|
target: 'task' | 'markdown';
|
|
taskId?: string;
|
|
tempId?: string; // Temporary ID for tracking new tasks
|
|
parentId?: string | null;
|
|
data?: Partial<Task>;
|
|
markdownLine?: number;
|
|
}
|
|
|
|
// Extended sync result structure
|
|
export interface ExtendedSyncResult extends BaseSyncResult {
|
|
success: boolean;
|
|
operations: SyncOperation[];
|
|
updatedMarkdown: string;
|
|
error?: string;
|
|
}
|
|
|
|
// Parse markdown checklist to tree structure
|
|
export function parseMarkdownToTree(markdown: string): TreeNode[] {
|
|
const lines = markdown.split('\n');
|
|
const rootNodes: TreeNode[] = [];
|
|
const stack: { node: TreeNode; indent: number }[] = [];
|
|
|
|
lines.forEach((line, lineIndex) => {
|
|
// Match checkbox items with either - or * as bullet
|
|
const checkboxMatch = line.match(/^(\s*)([-*])\s*\[([ x])\]\s*(.*)$/);
|
|
if (!checkboxMatch) return;
|
|
|
|
const [, indent, bullet, checked, text] = checkboxMatch;
|
|
const indentLevel = indent.length;
|
|
const isDone = checked.toLowerCase() === 'x';
|
|
|
|
// Extract task ID if present
|
|
const idMatch = text.match(/^\(([^)]+)\)\s*(.+)$/);
|
|
const id = idMatch ? idMatch[1] : undefined;
|
|
const title = idMatch ? idMatch[2] : text;
|
|
|
|
const node: TreeNode = {
|
|
id,
|
|
title: title.trim(),
|
|
isDone,
|
|
children: [],
|
|
level: Math.floor(indentLevel / 2), // Assuming 2 spaces per level
|
|
};
|
|
|
|
// Find parent based on indentation
|
|
while (stack.length > 0 && stack[stack.length - 1].indent >= indentLevel) {
|
|
stack.pop();
|
|
}
|
|
|
|
if (stack.length === 0) {
|
|
rootNodes.push(node);
|
|
} else {
|
|
const parent = stack[stack.length - 1].node;
|
|
parent.children.push(node);
|
|
node.parentId = parent.id || null;
|
|
}
|
|
|
|
stack.push({ node, indent: indentLevel });
|
|
});
|
|
|
|
// Process notes (lines that should be added to task.notes)
|
|
const processNotes = (nodes: TreeNode[], parentStack: TreeNode[] = []) => {
|
|
nodes.forEach((node) => {
|
|
// Look for child nodes that aren't tasks (potential notes)
|
|
const notesLines: string[] = [];
|
|
const taskChildren: TreeNode[] = [];
|
|
|
|
node.children.forEach((child) => {
|
|
// If a child has no checkbox pattern, it might be notes
|
|
// This is handled by the parsing above, so all children here are tasks
|
|
taskChildren.push(child);
|
|
});
|
|
|
|
node.children = taskChildren;
|
|
|
|
// Recursively process children
|
|
processNotes(node.children, [...parentStack, node]);
|
|
});
|
|
};
|
|
|
|
processNotes(rootNodes);
|
|
|
|
return rootNodes;
|
|
}
|
|
|
|
// Convert task list to tree structure
|
|
export function tasksToTree(tasks: Task[]): TreeNode[] {
|
|
const taskMap = new Map<string, TreeNode>();
|
|
const rootNodes: TreeNode[] = [];
|
|
const childrenAdded = new Set<string>();
|
|
|
|
// First pass: create all nodes
|
|
tasks.forEach((task) => {
|
|
const node: TreeNode = {
|
|
id: task.id,
|
|
title: task.title,
|
|
isDone: task.isDone || false,
|
|
children: [],
|
|
notes: task.notes,
|
|
parentId: task.parentId || null,
|
|
level: 0,
|
|
};
|
|
taskMap.set(task.id, node);
|
|
});
|
|
|
|
// Second pass: build tree structure
|
|
tasks.forEach((task) => {
|
|
const node = taskMap.get(task.id)!;
|
|
|
|
// Handle parent-child relationships
|
|
if (task.parentId && taskMap.has(task.parentId)) {
|
|
const parent = taskMap.get(task.parentId)!;
|
|
if (!childrenAdded.has(task.id)) {
|
|
parent.children.push(node);
|
|
childrenAdded.add(task.id);
|
|
node.level = parent.level + 1;
|
|
}
|
|
}
|
|
|
|
// Handle subtask IDs - maintain order from subTaskIds array
|
|
if (task.subTaskIds && task.subTaskIds.length > 0) {
|
|
// Clear children array to rebuild in correct order
|
|
node.children = [];
|
|
|
|
// Add children in the order specified by subTaskIds
|
|
task.subTaskIds.forEach((subTaskId) => {
|
|
const child = taskMap.get(subTaskId);
|
|
if (child && !childrenAdded.has(subTaskId)) {
|
|
node.children.push(child);
|
|
child.parentId = task.id;
|
|
child.level = node.level + 1;
|
|
childrenAdded.add(subTaskId);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Add root nodes (tasks without parents and not already added as children)
|
|
if (!task.parentId && !childrenAdded.has(task.id)) {
|
|
rootNodes.push(node);
|
|
}
|
|
});
|
|
|
|
return rootNodes;
|
|
}
|
|
|
|
// Convert tree back to markdown
|
|
export function treeToMarkdown(
|
|
nodes: TreeNode[],
|
|
level: number = 0,
|
|
includeIds: boolean = false,
|
|
): string {
|
|
const lines: string[] = [];
|
|
|
|
nodes.forEach((node) => {
|
|
const indent = ' '.repeat(level);
|
|
const checkbox = node.isDone ? '[x]' : '[ ]';
|
|
const id = includeIds && node.id ? `(${node.id}) ` : '';
|
|
const line = `${indent}- ${checkbox} ${id}${node.title}`;
|
|
lines.push(line);
|
|
|
|
// Add notes as sub-items if present
|
|
// Only include notes for leaf tasks (tasks without children)
|
|
if (node.notes && node.children.length === 0) {
|
|
const noteLines = node.notes.split('\n').filter((line) => line.trim());
|
|
noteLines.forEach((noteLine) => {
|
|
const trimmedLine = noteLine.trim();
|
|
// Check if the note line is a checklist item
|
|
const checklistMatch = trimmedLine.match(/^[-*]\s*\[([ x])\]\s*(.+)$/);
|
|
if (checklistMatch) {
|
|
const [, checked, text] = checklistMatch;
|
|
const noteCheckbox = checked.toLowerCase() === 'x' ? '[x]' : '[ ]';
|
|
lines.push(`${indent} - ${noteCheckbox} ${text}`);
|
|
} else {
|
|
// For non-checklist notes, add as plain text sub-item
|
|
lines.push(`${indent} - ${trimmedLine}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Recursively add children
|
|
if (node.children.length > 0) {
|
|
lines.push(treeToMarkdown(node.children, level + 1, includeIds));
|
|
}
|
|
});
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// Helper to find matching node
|
|
const findMatchingNode = (node: TreeNode, tree: TreeNode[]): TreeNode | null => {
|
|
for (const treeNode of tree) {
|
|
// Match by ID if available
|
|
if (node.id && treeNode.id && node.id === treeNode.id) {
|
|
return treeNode;
|
|
}
|
|
// Otherwise match by title and parent context
|
|
if (node.title === treeNode.title) {
|
|
return treeNode;
|
|
}
|
|
// Recursively search in children
|
|
const found = findMatchingNode(node, treeNode.children);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// Compare two trees and generate sync operations
|
|
export function compareTrees(
|
|
markdownTree: TreeNode[],
|
|
taskTree: TreeNode[],
|
|
direction: SyncDirection,
|
|
): SyncOperation[] {
|
|
const operations: SyncOperation[] = [];
|
|
|
|
// Process based on sync direction
|
|
if (direction === 'fileToProject' || direction === 'bidirectional') {
|
|
// Process markdown nodes
|
|
const processMarkdownNode = (node: TreeNode, parentId: string | null = null) => {
|
|
const matchingTask = findMatchingNode(node, taskTree);
|
|
|
|
if (!matchingTask) {
|
|
// Add new task
|
|
operations.push({
|
|
type: 'add',
|
|
target: 'task',
|
|
parentId,
|
|
data: {
|
|
title: node.title,
|
|
isDone: node.isDone,
|
|
notes: node.notes,
|
|
},
|
|
});
|
|
} else if (
|
|
matchingTask.isDone !== node.isDone ||
|
|
matchingTask.title !== node.title
|
|
) {
|
|
// Update existing task
|
|
operations.push({
|
|
type: 'update',
|
|
target: 'task',
|
|
taskId: matchingTask.id,
|
|
data: {
|
|
title: node.title,
|
|
isDone: node.isDone,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Process children
|
|
node.children.forEach((child) => {
|
|
processMarkdownNode(child, node.id || matchingTask?.id || null);
|
|
});
|
|
};
|
|
|
|
markdownTree.forEach((node) => processMarkdownNode(node));
|
|
}
|
|
|
|
if (direction === 'projectToFile' || direction === 'bidirectional') {
|
|
// Process task nodes
|
|
const processTaskNode = (node: TreeNode) => {
|
|
const matchingMarkdown = findMatchingNode(node, markdownTree);
|
|
|
|
if (!matchingMarkdown) {
|
|
if (direction === 'projectToFile') {
|
|
// Task exists in project but not in markdown - add it
|
|
operations.push({
|
|
type: 'add',
|
|
target: 'markdown',
|
|
taskId: node.id,
|
|
data: {
|
|
title: node.title,
|
|
isDone: node.isDone,
|
|
notes: node.notes,
|
|
},
|
|
});
|
|
} else if (direction === 'bidirectional') {
|
|
// In bidirectional, delete the task from project
|
|
operations.push({
|
|
type: 'delete',
|
|
target: 'task',
|
|
taskId: node.id,
|
|
});
|
|
}
|
|
} else if (
|
|
matchingMarkdown.isDone !== node.isDone ||
|
|
matchingMarkdown.title !== node.title
|
|
) {
|
|
// Task exists in both but has been modified in project - update markdown
|
|
operations.push({
|
|
type: 'update',
|
|
target: 'markdown',
|
|
taskId: node.id,
|
|
data: {
|
|
title: node.title,
|
|
isDone: node.isDone,
|
|
notes: node.notes,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Process children
|
|
node.children.forEach(processTaskNode);
|
|
};
|
|
|
|
taskTree.forEach(processTaskNode);
|
|
|
|
// Check for markdown nodes that don't exist in project (deleted tasks)
|
|
const processMarkdownNodeForDeletion = (node: TreeNode) => {
|
|
const matchingTask = findMatchingNode(node, taskTree);
|
|
|
|
if (!matchingTask) {
|
|
// Task exists in markdown but not in project - remove from markdown
|
|
operations.push({
|
|
type: 'delete',
|
|
target: 'markdown',
|
|
taskId: node.id,
|
|
data: {
|
|
title: node.title,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Process children
|
|
node.children.forEach(processMarkdownNodeForDeletion);
|
|
};
|
|
|
|
markdownTree.forEach(processMarkdownNodeForDeletion);
|
|
}
|
|
|
|
return operations;
|
|
}
|
|
|
|
// Main replication function
|
|
export function replicateMD(
|
|
markdownContent: string,
|
|
tasks: Task[],
|
|
direction: SyncDirection,
|
|
): ExtendedSyncResult {
|
|
try {
|
|
// Parse both sources to tree structures
|
|
const markdownTree = parseMarkdownToTree(markdownContent);
|
|
const taskTree = tasksToTree(tasks);
|
|
|
|
// Compare and generate operations
|
|
const operations = compareTrees(markdownTree, taskTree, direction);
|
|
|
|
// Apply operations to generate updated markdown
|
|
let updatedTree = JSON.parse(JSON.stringify(markdownTree)); // Deep clone the tree
|
|
|
|
if (direction === 'projectToFile' || direction === 'bidirectional') {
|
|
// Helper function to find a node by ID in the tree
|
|
const findNodeById = (
|
|
id: string | undefined,
|
|
tree: TreeNode[],
|
|
): TreeNode | null => {
|
|
if (!id) return null;
|
|
for (const node of tree) {
|
|
if (node.id === id) return node;
|
|
const found = findNodeById(id, node.children);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// Helper function to remove a node by ID or title from the tree
|
|
const removeNodeByIdOrTitle = (
|
|
id: string | undefined,
|
|
title: string | undefined,
|
|
tree: TreeNode[],
|
|
): boolean => {
|
|
if (!id && !title) {
|
|
return false;
|
|
}
|
|
for (let i = 0; i < tree.length; i++) {
|
|
// Try to match by ID first, then by title
|
|
const isMatch = (id && tree[i].id === id) || (title && tree[i].title === title);
|
|
if (isMatch) {
|
|
tree.splice(i, 1);
|
|
return true;
|
|
}
|
|
if (removeNodeByIdOrTitle(id, title, tree[i].children)) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// For projectToFile sync, use SuperProductivity's task order
|
|
if (direction === 'projectToFile') {
|
|
// Helper to build children tree with correct hierarchy
|
|
const buildChildrenTree = (
|
|
children: TreeNode[],
|
|
level: number = 1,
|
|
): TreeNode[] => {
|
|
return children.map((child) => ({
|
|
id: child.id,
|
|
title: child.title,
|
|
isDone: child.isDone,
|
|
notes: child.notes,
|
|
level: level,
|
|
children: buildChildrenTree(child.children, level + 1),
|
|
}));
|
|
};
|
|
|
|
// Use SuperProductivity's task order directly
|
|
updatedTree = taskTree.map((taskNode) => ({
|
|
id: taskNode.id,
|
|
title: taskNode.title,
|
|
isDone: taskNode.isDone,
|
|
notes: taskNode.notes,
|
|
level: 0,
|
|
children: buildChildrenTree(taskNode.children),
|
|
}));
|
|
} else {
|
|
// For bidirectional sync, apply operations incrementally
|
|
operations
|
|
.filter((op) => op.target === 'markdown')
|
|
.forEach((op) => {
|
|
if (op.type === 'add' && op.data) {
|
|
// Add new task to the root of the tree
|
|
const newNode: TreeNode = {
|
|
id: op.taskId,
|
|
title: op.data.title || '',
|
|
isDone: op.data.isDone || false,
|
|
children: [],
|
|
notes: op.data.notes,
|
|
level: 0,
|
|
};
|
|
updatedTree.push(newNode);
|
|
} else if (op.type === 'update' && op.data) {
|
|
// Find and update the existing node
|
|
const node = findNodeById(op.taskId, updatedTree);
|
|
if (node) {
|
|
node.title = op.data.title || node.title;
|
|
node.isDone = op.data.isDone !== undefined ? op.data.isDone : node.isDone;
|
|
if (op.data.notes !== undefined) {
|
|
node.notes = op.data.notes;
|
|
}
|
|
}
|
|
} else if (op.type === 'delete') {
|
|
// Remove the node from the tree
|
|
removeNodeByIdOrTitle(op.taskId, op.data?.title, updatedTree);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
const updatedMarkdown = treeToMarkdown(updatedTree, 0, false); // Don't include IDs in output
|
|
|
|
// Calculate summary statistics
|
|
const tasksAdded = operations.filter(
|
|
(op) => op.type === 'add' && op.target === 'task',
|
|
).length;
|
|
const tasksUpdated = operations.filter(
|
|
(op) => op.type === 'update' && op.target === 'task',
|
|
).length;
|
|
const tasksDeleted = operations.filter(
|
|
(op) => op.type === 'delete' && op.target === 'task',
|
|
).length;
|
|
|
|
return {
|
|
success: true,
|
|
operations,
|
|
updatedMarkdown,
|
|
conflicts: [],
|
|
tasksAdded,
|
|
tasksUpdated,
|
|
tasksDeleted,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
operations: [],
|
|
updatedMarkdown: markdownContent,
|
|
conflicts: [],
|
|
error: error instanceof Error ? error.message : String(error),
|
|
tasksAdded: 0,
|
|
tasksUpdated: 0,
|
|
tasksDeleted: 0,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Sync state for tracking changes
|
|
export interface SyncState {
|
|
lastSyncTime: number;
|
|
fileChecksum: string;
|
|
taskChecksums: Map<string, string>;
|
|
}
|
|
|
|
export class BidirectionalSync {
|
|
private syncState: SyncState | null = null;
|
|
|
|
async sync(
|
|
markdownContent: string,
|
|
tasks: Task[],
|
|
direction: SyncDirection,
|
|
previousState?: SyncState,
|
|
): Promise<ExtendedSyncResult> {
|
|
this.syncState = previousState || null;
|
|
return replicateMD(markdownContent, tasks, direction);
|
|
}
|
|
|
|
updateSyncState(markdownContent: string, tasks: Task[]): SyncState {
|
|
// Simple checksum implementation
|
|
const fileChecksum = this.simpleChecksum(markdownContent);
|
|
const taskChecksums = new Map<string, string>();
|
|
|
|
tasks.forEach((task) => {
|
|
taskChecksums.set(task.id, this.simpleChecksum(JSON.stringify(task)));
|
|
});
|
|
|
|
this.syncState = {
|
|
lastSyncTime: Date.now(),
|
|
fileChecksum,
|
|
taskChecksums,
|
|
};
|
|
|
|
return this.syncState;
|
|
}
|
|
|
|
private simpleChecksum(str: string): string {
|
|
let hash = 0;
|
|
for (let i = 0; i < str.length; i++) {
|
|
const char = str.charCodeAt(i);
|
|
hash = (hash << 5) - hash + char;
|
|
hash = hash & hash;
|
|
}
|
|
return hash.toString(16);
|
|
}
|
|
}
|