UNPKG

markmv

Version:

TypeScript CLI for markdown file operations with intelligent link refactoring

468 lines 19.1 kB
/** * Abstract base class for all merge strategies. * * Provides common functionality for merging markdown files including transclusion handling, * conflict detection, and frontmatter management. Concrete strategies implement specific merging * approaches. * * @category Strategies * * @example * Implementing a custom merge strategy * ```typescript * class CustomMergeStrategy extends BaseMergeStrategy { * async merge(targetContent: string, sourceContent: string): Promise<MergeResult> { * // Custom merging logic * const conflicts = this.detectConflicts(targetContent, sourceContent); * return this.buildResult(mergedContent, conflicts); * } * } * ``` */ export class BaseMergeStrategy { options; constructor(options = {}) { this.options = { conflictResolution: 'auto', separator: '\n\n', createTransclusions: false, mergeFrontmatter: true, preserveStructure: true, transclusionTemplate: '![[{file}#{section}]]', maxTransclusionDepth: 3, ...options, }; } /** Extract Obsidian transclusions from content */ extractTransclusions(content) { const transclusions = []; const lines = content.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Match Obsidian transclusion syntax: ![[file]] or ![[file#section]] const matches = line.matchAll(/!\[\[([^#\]]+)(?:#([^\]]+))?\]\]/g); for (const match of matches) { const fullRef = match[0]; const file = match[1]; const section = match[2]; transclusions.push({ ref: fullRef, file: file.endsWith('.md') ? file : `${file}.md`, section, line: i + 1, }); } } return transclusions; } /** Create an Obsidian transclusion reference */ createTransclusion(file, section) { const template = this.options.transclusionTemplate || '![[{file}#{section}]]'; const cleanFile = file.replace(/\.md$/, ''); if (section) { return template.replace('{file}', cleanFile).replace('{section}', section); } return template.replace('{file}', cleanFile).replace('#{section}', ''); } /** Detect potential transclusion loops */ detectTransclusionLoops(targetFile, sourceFile, existingTransclusions) { // Check if source file already references target file if (existingTransclusions.some((t) => t.includes(targetFile.replace(/\.md$/, '')))) { return true; } // Check for direct circular reference if (sourceFile === targetFile) { return true; } return false; } /** Merge frontmatter from two sources */ mergeFrontmatter(targetFrontmatter, sourceFrontmatter) { if (!targetFrontmatter && !sourceFrontmatter) { return ''; } if (!targetFrontmatter) return sourceFrontmatter; if (!sourceFrontmatter) return targetFrontmatter; const targetData = this.parseFrontmatter(targetFrontmatter); const sourceData = this.parseFrontmatter(sourceFrontmatter); // Merge data - target takes precedence for conflicts const mergedData = { ...sourceData, ...targetData }; // Special handling for arrays (tags, categories) for (const key of ['tags', 'categories', 'keywords']) { const sourceValue = sourceData[key]; const targetValue = targetData[key]; if (Array.isArray(sourceValue) && Array.isArray(targetValue)) { mergedData[key] = [...new Set([...sourceValue, ...targetValue])]; } } return this.stringifyFrontmatter(mergedData); } parseFrontmatter(frontmatter) { const data = {}; const lines = frontmatter .replace(/^---\n/, '') .replace(/\n---$/, '') .split('\n'); for (const line of lines) { const match = line.match(/^([^:]+):\s*(.*)$/); if (match) { const key = match[1].trim(); const value = match[2].trim(); if (value.startsWith('[') && value.endsWith(']')) { // Parse array data[key] = value .slice(1, -1) .split(',') .map((item) => item.trim().replace(/['"]/g, '')); } else { data[key] = value.replace(/['"]/g, ''); } } } return data; } stringifyFrontmatter(data) { if (Object.keys(data).length === 0) { return ''; } let result = '---\n'; for (const [key, value] of Object.entries(data)) { if (Array.isArray(value)) { result += `${key}: [${value.map((v) => `"${v}"`).join(', ')}]\n`; } else { result += `${key}: "${value}"\n`; } } result += '---\n'; return result; } /** Extract headers from content with their levels */ extractHeaders(content) { const headers = []; const lines = content.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i]; const match = line.match(/^(#+)\s+(.+)$/); if (match) { headers.push({ text: match[2].trim(), level: match[1].length, line: i + 1, }); } } return headers; } /** Find potential header conflicts between target and source */ findHeaderConflicts(targetContent, sourceContent) { const targetHeaders = this.extractHeaders(targetContent); const sourceHeaders = this.extractHeaders(sourceContent); const conflicts = []; for (const sourceHeader of sourceHeaders) { const conflict = targetHeaders.find((th) => th.text.toLowerCase() === sourceHeader.text.toLowerCase() && th.level === sourceHeader.level); if (conflict) { conflicts.push({ header: sourceHeader.text, targetLine: conflict.line, sourceLine: sourceHeader.line, }); } } return conflicts; } } /** * Merge strategy that appends source content to the end of target content. * * Simply adds the source file content to the end of the target file, with optional separator and * frontmatter merging. This is the simplest merge strategy and works well for accumulating * content. * * @category Strategies * * @example * Append merge with transclusions * ```typescript * const strategy = new AppendMergeStrategy({ * createTransclusions: true, * separator: '\n\n---\n\n' * }); * * const result = await strategy.merge(targetContent, sourceContent, 'target.md', 'source.md'); * console.log(`Appended content, ${result.transclusions.length} transclusions created`); * ``` */ export class AppendMergeStrategy extends BaseMergeStrategy { async merge(targetContent, sourceContent, targetFile, sourceFile) { const errors = []; const warnings = []; const conflicts = []; const transclusions = []; try { // Extract frontmatter const targetFrontmatter = this.extractFrontmatterFromContent(targetContent); const sourceFrontmatter = this.extractFrontmatterFromContent(sourceContent); const targetMainContent = this.stripFrontmatter(targetContent); const sourceMainContent = this.stripFrontmatter(sourceContent); // Check for header conflicts const headerConflicts = this.findHeaderConflicts(targetMainContent, sourceMainContent); for (const conflict of headerConflicts) { conflicts.push({ type: 'header-collision', description: `Duplicate header "${conflict.header}" found in both files`, sourceFiles: [targetFile, sourceFile], resolution: 'Headers will be preserved as-is', lines: [conflict.targetLine, conflict.sourceLine], autoResolved: true, }); } // Check for transclusion loops if creating transclusions if (this.options.createTransclusions) { const existingTransclusions = this.extractTransclusions(targetMainContent); const transclusionRefs = existingTransclusions.map((t) => t.ref); if (this.detectTransclusionLoops(targetFile, sourceFile, transclusionRefs)) { warnings.push('Transclusion loop detected - not creating transclusion reference'); } else { // Create transclusion instead of appending content const transclusionRef = this.createTransclusion(sourceFile); const finalContent = targetMainContent + this.options.separator + transclusionRef; transclusions.push(transclusionRef); return { success: true, content: finalContent, frontmatter: this.options.mergeFrontmatter ? this.mergeFrontmatter(targetFrontmatter, sourceFrontmatter) : targetFrontmatter, sourceFiles: [targetFile, sourceFile], conflicts, warnings, errors, transclusions, }; } } // Standard append merge const separator = this.options.separator || '\n\n'; const mergedContent = targetMainContent + separator + sourceMainContent; return { success: true, content: mergedContent, frontmatter: this.options.mergeFrontmatter ? this.mergeFrontmatter(targetFrontmatter, sourceFrontmatter) : targetFrontmatter, sourceFiles: [targetFile, sourceFile], conflicts, warnings, errors, transclusions, }; } catch (error) { errors.push(`Failed to merge files: ${error}`); return { success: false, content: targetContent, sourceFiles: [targetFile, sourceFile], conflicts, warnings, errors, transclusions, }; } } extractFrontmatterFromContent(content) { const match = content.match(/^---\n(.*?)\n---\n/s); return match ? match[0] : ''; } stripFrontmatter(content) { return content.replace(/^---\n.*?\n---\n/s, '').trim(); } } /** * Merge strategy that prepends source content to the beginning of target content. * * Adds the source file content to the beginning of the target file, after any frontmatter. This is * useful when you want new content to appear first in the document. * * @category Strategies * * @example * Prepend merge with custom separator * ```typescript * const strategy = new PrependMergeStrategy({ * separator: '\n\n<!-- New Content Above -->\n\n', * mergeFrontmatter: true * }); * * const result = await strategy.merge(targetContent, sourceContent, 'target.md', 'source.md'); * console.log('Source content prepended to target'); * ``` */ export class PrependMergeStrategy extends BaseMergeStrategy { async merge(targetContent, sourceContent, targetFile, sourceFile) { const errors = []; const warnings = []; const conflicts = []; const transclusions = []; try { // Extract frontmatter const targetFrontmatter = this.extractFrontmatterFromContent(targetContent); const sourceFrontmatter = this.extractFrontmatterFromContent(sourceContent); const targetMainContent = this.stripFrontmatter(targetContent); const sourceMainContent = this.stripFrontmatter(sourceContent); // Check for header conflicts const headerConflicts = this.findHeaderConflicts(targetMainContent, sourceMainContent); for (const conflict of headerConflicts) { conflicts.push({ type: 'header-collision', description: `Duplicate header "${conflict.header}" found in both files`, sourceFiles: [targetFile, sourceFile], resolution: 'Headers will be preserved as-is', lines: [conflict.targetLine, conflict.sourceLine], autoResolved: true, }); } // Standard prepend merge const separator = this.options.separator || '\n\n'; const mergedContent = sourceMainContent + separator + targetMainContent; return { success: true, content: mergedContent, frontmatter: this.options.mergeFrontmatter ? this.mergeFrontmatter(targetFrontmatter, sourceFrontmatter) : targetFrontmatter, sourceFiles: [targetFile, sourceFile], conflicts, warnings, errors, transclusions, }; } catch (error) { errors.push(`Failed to merge files: ${error}`); return { success: false, content: targetContent, sourceFiles: [targetFile, sourceFile], conflicts, warnings, errors, transclusions, }; } } extractFrontmatterFromContent(content) { const match = content.match(/^---\n(.*?)\n---\n/s); return match ? match[0] : ''; } stripFrontmatter(content) { return content.replace(/^---\n.*?\n---\n/s, '').trim(); } } /** * Merge strategy that provides intelligent conflict detection and resolution. * * Analyzes both files to detect potential conflicts such as duplicate headers, overlapping content, * or structural issues. Provides automatic resolution where possible and clear reporting of * conflicts that need manual attention. * * @category Strategies * * @example * Interactive merge with conflict resolution * ```typescript * const strategy = new InteractiveMergeStrategy({ * conflictResolution: 'auto', * createTransclusions: true, * preserveStructure: true * }); * * const result = await strategy.merge(targetContent, sourceContent, 'target.md', 'source.md'); * console.log(`Merge completed with ${result.conflicts.length} conflicts detected`); * ``` */ export class InteractiveMergeStrategy extends BaseMergeStrategy { async merge(targetContent, sourceContent, targetFile, sourceFile) { const errors = []; const warnings = []; const conflicts = []; const transclusions = []; try { // Extract frontmatter and content const targetFrontmatter = this.extractFrontmatterFromContent(targetContent); const sourceFrontmatter = this.extractFrontmatterFromContent(sourceContent); const targetMainContent = this.stripFrontmatter(targetContent); const sourceMainContent = this.stripFrontmatter(sourceContent); // Analyze content for interactive decision points const headerConflicts = this.findHeaderConflicts(targetMainContent, sourceMainContent); const sourceHeaders = this.extractHeaders(sourceMainContent); // Create interactive conflicts for each decision point for (const conflict of headerConflicts) { conflicts.push({ type: 'header-collision', description: `Duplicate header "${conflict.header}" - choose resolution strategy`, sourceFiles: [targetFile, sourceFile], resolution: 'Manual resolution required: rename, merge, or skip', lines: [conflict.targetLine, conflict.sourceLine], autoResolved: false, }); } // Create sections for each source header to allow interactive placement for (const header of sourceHeaders) { // const sectionContent = this.extractSectionContent(sourceMainContent, header); conflicts.push({ type: 'content-overlap', description: `Place section "${header.text}" from ${sourceFile}`, sourceFiles: [sourceFile], resolution: 'Choose position: before, after, or replace existing content', lines: [header.line], autoResolved: false, }); } // For now, create a basic merge that requires manual resolution warnings.push('Interactive merge requires manual conflict resolution'); warnings.push('Use CLI interactive mode or resolve conflicts manually'); // Create a basic append merge as fallback const separator = this.options.separator || '\n\n---\n\n'; const mergedContent = `${targetMainContent + separator}<!-- MERGE CONFLICT: Review and resolve manually -->${separator}${sourceMainContent}`; return { success: true, content: mergedContent, frontmatter: this.options.mergeFrontmatter ? this.mergeFrontmatter(targetFrontmatter, sourceFrontmatter) : targetFrontmatter, sourceFiles: [targetFile, sourceFile], conflicts, warnings, errors, transclusions, }; } catch (error) { errors.push(`Failed to perform interactive merge: ${error}`); return { success: false, content: targetContent, sourceFiles: [targetFile, sourceFile], conflicts, warnings, errors, transclusions, }; } } extractFrontmatterFromContent(content) { const match = content.match(/^---\n(.*?)\n---\n/s); return match ? match[0] : ''; } stripFrontmatter(content) { return content.replace(/^---\n.*?\n---\n/s, '').trim(); } } //# sourceMappingURL=merge-strategies.js.map