314 lines
8.8 KiB
TypeScript
314 lines
8.8 KiB
TypeScript
import { parseIndentLevel } from './todo-parser';
|
|
|
|
/**
|
|
* Regex pattern for markdown todo checkboxes (for toggling).
|
|
*/
|
|
const TODO_PATTERN = /^(\s*[-*+]\s+\[)([xX ]?)(\].*)$/;
|
|
|
|
/**
|
|
* Toggle the checkbox state at the specified line number.
|
|
* Returns the modified content.
|
|
*/
|
|
export function toggleTodo(content: string, lineNumber: number): string {
|
|
const lines = content.split('\n');
|
|
|
|
if (lineNumber < 0 || lineNumber >= lines.length) {
|
|
return content;
|
|
}
|
|
|
|
const line = lines[lineNumber];
|
|
if (line === undefined) {
|
|
return content;
|
|
}
|
|
|
|
const match = line.match(TODO_PATTERN);
|
|
if (!match) {
|
|
return content;
|
|
}
|
|
|
|
const checkboxContent = match[2];
|
|
const isChecked = checkboxContent === 'x' || checkboxContent === 'X';
|
|
const newCheckbox = isChecked ? ' ' : 'x';
|
|
|
|
lines[lineNumber] = `${match[1]}${newCheckbox}${match[3]}`;
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Remove the line at the specified line number.
|
|
* Returns the modified content.
|
|
*/
|
|
export function removeTodoLine(content: string, lineNumber: number): string {
|
|
const lines = content.split('\n');
|
|
|
|
if (lineNumber < 0 || lineNumber >= lines.length) {
|
|
return content;
|
|
}
|
|
|
|
lines.splice(lineNumber, 1);
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Insert a todo line at the beginning of the content.
|
|
*/
|
|
export function insertTodoAtBeginning(content: string, todoLine: string): string {
|
|
if (!content) {
|
|
return todoLine;
|
|
}
|
|
return todoLine + '\n' + content;
|
|
}
|
|
|
|
/**
|
|
* Insert a todo line at the end of the content.
|
|
* Handles files with/without trailing newlines.
|
|
*/
|
|
export function insertTodoAtEnd(content: string, todoLine: string): string {
|
|
if (!content) {
|
|
return todoLine;
|
|
}
|
|
|
|
// If content ends with newline(s), append directly
|
|
if (content.endsWith('\n')) {
|
|
return content + todoLine;
|
|
}
|
|
|
|
// Otherwise add a newline before the todo
|
|
return content + '\n' + todoLine;
|
|
}
|
|
|
|
/**
|
|
* Move a todo (and its children) from one position to another within the same content.
|
|
* @param content - The file content
|
|
* @param todoLineNumber - Line number of the todo to move
|
|
* @param childLineNumbers - Line numbers of child todos (must be consecutive after todoLineNumber)
|
|
* @param targetLineNumber - Line number to insert before (0-based). Lines are inserted at this position.
|
|
*/
|
|
export function moveTodoWithChildren(
|
|
content: string,
|
|
todoLineNumber: number,
|
|
childLineNumbers: number[],
|
|
targetLineNumber: number
|
|
): string {
|
|
const lines = content.split('\n');
|
|
const allLineNumbers = [todoLineNumber, ...childLineNumbers].sort((a, b) => a - b);
|
|
|
|
// Extract the lines to move
|
|
const movedLines = allLineNumbers.map((ln) => lines[ln]!);
|
|
|
|
// If moving to same position, no-op
|
|
if (targetLineNumber === todoLineNumber) {
|
|
return content;
|
|
}
|
|
|
|
// Remove lines from original positions (in reverse to preserve indices)
|
|
for (let i = allLineNumbers.length - 1; i >= 0; i--) {
|
|
lines.splice(allLineNumbers[i]!, 1);
|
|
}
|
|
|
|
// Adjust target position for removed lines
|
|
let adjustedTarget = targetLineNumber;
|
|
for (const ln of allLineNumbers) {
|
|
if (ln < targetLineNumber) {
|
|
adjustedTarget--;
|
|
}
|
|
}
|
|
|
|
// Insert at adjusted target
|
|
lines.splice(adjustedTarget, 0, ...movedLines);
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Move a todo (and its children) under a heading within the same file.
|
|
* Inserts at the end of the heading's section.
|
|
* @param content - The file content
|
|
* @param todoLineNumber - Line number of the todo to move
|
|
* @param childLineNumbers - Line numbers of child todos
|
|
* @param headingLineNumber - Line number of the target heading
|
|
* @param nextHeadingLineNumber - Optional line number of the next heading (section boundary)
|
|
*/
|
|
export function moveTodoUnderHeadingInFile(
|
|
content: string,
|
|
todoLineNumber: number,
|
|
childLineNumbers: number[],
|
|
headingLineNumber: number,
|
|
nextHeadingLineNumber?: number
|
|
): string {
|
|
// Determine insert position: end of section (before next heading) or after heading
|
|
let targetLineNumber: number;
|
|
if (nextHeadingLineNumber !== undefined) {
|
|
targetLineNumber = nextHeadingLineNumber;
|
|
} else {
|
|
// No next heading — insert at end of file
|
|
const lines = content.split('\n');
|
|
targetLineNumber = lines.length;
|
|
}
|
|
|
|
return moveTodoWithChildren(content, todoLineNumber, childLineNumbers, targetLineNumber);
|
|
}
|
|
|
|
/**
|
|
* Adjust indentation of todo lines by a delta (positive = indent, negative = outdent).
|
|
* Each level is 2 spaces. Will not outdent past zero.
|
|
*/
|
|
export function indentTodoLines(todoLines: string[], indentDelta: number): string[] {
|
|
if (indentDelta === 0) return [...todoLines];
|
|
|
|
const indent = ' '; // 2 spaces per level
|
|
|
|
return todoLines.map((line) => {
|
|
if (indentDelta > 0) {
|
|
return indent.repeat(indentDelta) + line;
|
|
} else {
|
|
// Outdent: remove leading spaces
|
|
let result = line;
|
|
for (let i = 0; i < Math.abs(indentDelta); i++) {
|
|
if (result.startsWith(indent)) {
|
|
result = result.slice(indent.length);
|
|
} else if (result.startsWith('\t')) {
|
|
result = result.slice(1);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Insert a todo line (or block of lines) under a specific heading.
|
|
* @param content - The file content
|
|
* @param headingLineNumber - The line number of the heading (0-based)
|
|
* @param todoLine - The todo line(s) to insert (single string or array of strings)
|
|
* @param nextHeadingLineNumber - Optional line number of the next heading (for section boundary)
|
|
* @param addBlankLine - Whether to add a blank line between the heading and the todo
|
|
*/
|
|
export function insertTodoUnderHeading(
|
|
content: string,
|
|
headingLineNumber: number,
|
|
todoLine: string | string[],
|
|
nextHeadingLineNumber?: number,
|
|
addBlankLine = false
|
|
): string {
|
|
const lines = content.split('\n');
|
|
const todoLines = Array.isArray(todoLine) ? todoLine : [todoLine];
|
|
|
|
// Determine where to insert
|
|
let insertPosition: number;
|
|
|
|
if (nextHeadingLineNumber !== undefined) {
|
|
insertPosition = nextHeadingLineNumber;
|
|
} else {
|
|
insertPosition = headingLineNumber + 1;
|
|
}
|
|
|
|
if (addBlankLine) {
|
|
// Check if there's already a blank line at the insert position
|
|
const lineAfterHeading = lines[headingLineNumber + 1];
|
|
const alreadyHasBlank = lineAfterHeading !== undefined && lineAfterHeading.trim() === '';
|
|
|
|
if (alreadyHasBlank) {
|
|
// Insert after the existing blank line
|
|
lines.splice(headingLineNumber + 2, 0, ...todoLines);
|
|
} else {
|
|
// Insert blank line + todos
|
|
lines.splice(insertPosition, 0, '', ...todoLines);
|
|
}
|
|
} else {
|
|
lines.splice(insertPosition, 0, ...todoLines);
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Collect all line numbers that belong to a todo's block (by indentation).
|
|
* A line is part of the block if its indentation is greater than the todo's.
|
|
* Stops at blank line or line with same/lesser indentation.
|
|
*/
|
|
export function collectTodoBlockLines(content: string, todoLineNumber: number): number[] {
|
|
const lines = content.split('\n');
|
|
const todoLine = lines[todoLineNumber];
|
|
if (!todoLine) return [];
|
|
|
|
const todoIndent = parseIndentLevel(todoLine);
|
|
const blockLines: number[] = [];
|
|
|
|
for (let i = todoLineNumber + 1; i < lines.length; i++) {
|
|
const line = lines[i]!;
|
|
|
|
// Stop at blank line
|
|
if (line.trim() === '') {
|
|
break;
|
|
}
|
|
|
|
// Stop at same or lesser indentation
|
|
if (parseIndentLevel(line) <= todoIndent) {
|
|
break;
|
|
}
|
|
|
|
blockLines.push(i);
|
|
}
|
|
|
|
return blockLines;
|
|
}
|
|
|
|
/**
|
|
* Remove a todo and all its block lines (children, nested content, etc.).
|
|
*/
|
|
export function removeTodoBlock(content: string, todoLineNumber: number): string {
|
|
const blockLines = collectTodoBlockLines(content, todoLineNumber);
|
|
const allLineNumbers = [todoLineNumber, ...blockLines].sort((a, b) => a - b);
|
|
const lines = content.split('\n');
|
|
|
|
// Remove in reverse to preserve indices
|
|
for (let i = allLineNumbers.length - 1; i >= 0; i--) {
|
|
lines.splice(allLineNumbers[i]!, 1);
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Move a todo block to a new position with optional indentation adjustment.
|
|
* Auto-detects all lines in the block by indentation.
|
|
* @param indentDelta - Positive = indent, negative = outdent
|
|
*/
|
|
export function moveTodoBlock(
|
|
content: string,
|
|
todoLineNumber: number,
|
|
targetLineNumber: number,
|
|
indentDelta = 0
|
|
): string {
|
|
const blockLines = collectTodoBlockLines(content, todoLineNumber);
|
|
const allLineNumbers = [todoLineNumber, ...blockLines].sort((a, b) => a - b);
|
|
const lines = content.split('\n');
|
|
|
|
// Extract lines to move
|
|
let movedLines = allLineNumbers.map((ln) => lines[ln]!);
|
|
|
|
// Apply indentation if needed
|
|
if (indentDelta !== 0) {
|
|
movedLines = indentTodoLines(movedLines, indentDelta);
|
|
}
|
|
|
|
// Remove original lines (in reverse to preserve indices)
|
|
for (let i = allLineNumbers.length - 1; i >= 0; i--) {
|
|
lines.splice(allLineNumbers[i]!, 1);
|
|
}
|
|
|
|
// Adjust target position for removed lines
|
|
let adjustedTarget = targetLineNumber;
|
|
for (const ln of allLineNumbers) {
|
|
if (ln < targetLineNumber) {
|
|
adjustedTarget--;
|
|
}
|
|
}
|
|
|
|
// Insert at adjusted target
|
|
lines.splice(adjustedTarget, 0, ...movedLines);
|
|
|
|
return lines.join('\n');
|
|
}
|