""" Document manager for high-performance markdown file ingestion and AST caching. This module implements the core functionality for Issue #2: Fast Document Loading & CLI Manipulation. It provides performance-optimized document processing through AST caching and database integration. Key Features: - Parse once, access many times architecture - AST cache loading < 50% of markdown parsing time - Seamless integration with Issue #1 database foundation - Comprehensive error handling and validation """ import json import time from pathlib import Path from typing import Dict, Any, Optional from .parser import parse_markdown_to_ast from .frontmatter import FrontMatterParser class DocumentManager: """ High-performance document manager for markdown file processing. Implements the "parse once, manipulate many times" architecture by creating fast-loading AST cache files alongside database metadata storage. Architecture: markdown file → AST parsing → cache file + database metadata Performance Goal: Cache loading must be < 50% of original parsing time Attributes: db_manager: Database manager for metadata storage cache_dir: Directory for AST cache files frontmatter_parser: YAML front matter processor """ def __init__(self, database_manager, cache_dir: Optional[Path] = None): """ Initialize document manager with database and cache configuration. Args: database_manager: DatabaseManager instance for metadata storage cache_dir: Directory for AST cache files (default: .ast_cache) """ self.db_manager = database_manager self.cache_dir = Path(cache_dir) if cache_dir else Path(".ast_cache") self.cache_dir.mkdir(exist_ok=True) self.frontmatter_parser = FrontMatterParser() def ingest_file(self, file_path: Path) -> Dict[str, Any]: """ Ingest a markdown file with performance-optimized AST caching. Implements the core "parse once, manipulate many times" workflow: 1. Validates file existence 2. Parses markdown content to AST 3. Creates fast-loading AST cache file 4. Stores metadata in database 5. Returns processing results with performance metrics Args: file_path: Path to markdown file to ingest Returns: Dictionary containing: - ast: Parsed AST representation - metadata: File metadata (filename, title, etc.) - ast_cache_path: Path to created cache file - parse_time: Time spent parsing markdown (seconds) - cache_time: Time spent creating cache (seconds) Raises: FileNotFoundError: If the specified file doesn't exist Performance: Initial parse creates overhead, but subsequent cache loads will be < 50% of this parse time. """ # Validate file exists if not file_path.exists(): raise FileNotFoundError(f"File not found: {file_path}") # Read file content content = self._read_file_content(file_path) # Parse front matter for metadata extraction front_matter, markdown_content = self.frontmatter_parser.parse(content) # Parse to AST with performance timing ast, parse_time = self._parse_content_to_ast(content) # Create cache file with performance timing cache_file, cache_time = self._create_performance_cache(file_path.name, ast) # Store in database (handles front matter parsing internally) self._store_in_database(file_path.name, content) # Return comprehensive result return self._build_ingestion_result( ast=ast, filename=file_path.name, front_matter=front_matter, cache_file=cache_file, parse_time=parse_time, cache_time=cache_time ) def _read_file_content(self, file_path: Path) -> str: """ Read file content with proper encoding. Args: file_path: Path to file to read Returns: File content as string """ return file_path.read_text(encoding='utf-8') def _parse_content_to_ast(self, content: str) -> tuple[list, float]: """ Parse markdown content to AST with performance timing. Args: content: Raw markdown content Returns: Tuple of (AST tokens, parse_time_seconds) """ start_time = time.time() ast = parse_markdown_to_ast(content) parse_time = time.time() - start_time return ast, parse_time def _create_performance_cache(self, filename: str, ast: list) -> tuple[Path, float]: """ Create AST cache file with performance timing. Args: filename: Source filename for cache naming ast: AST tokens to cache Returns: Tuple of (cache_file_path, cache_time_seconds) """ start_time = time.time() cache_file = self._create_ast_cache(filename, ast) cache_time = time.time() - start_time return cache_file, cache_time def _store_in_database(self, filename: str, content: str) -> None: """ Store document in database using existing API. Args: filename: Name of the file content: Full markdown content (including front matter) Note: The database manager handles front matter parsing internally. """ self.db_manager.store_markdown_file(filename, content) def _build_ingestion_result(self, ast: list, filename: str, front_matter: dict, cache_file: Path, parse_time: float, cache_time: float) -> Dict[str, Any]: """ Build comprehensive ingestion result dictionary. Args: ast: Parsed AST tokens filename: Source filename front_matter: Parsed front matter metadata cache_file: Path to created cache file parse_time: Time spent parsing (seconds) cache_time: Time spent caching (seconds) Returns: Structured result dictionary with all ingestion data """ return { 'ast': ast, 'metadata': { 'filename': filename, 'title': front_matter.get('title', ''), }, 'ast_cache_path': cache_file, 'parse_time': parse_time, 'cache_time': cache_time } def _create_ast_cache(self, filename: str, ast: list) -> Path: """ Create AST cache file in JSON format. Args: filename: Source filename for cache naming ast: AST tokens to serialize Returns: Path to created cache file """ cache_filename = f"{filename}.ast.json" cache_path = self.cache_dir / cache_filename with open(cache_path, 'w', encoding='utf-8') as f: json.dump(ast, f, indent=2, ensure_ascii=False) return cache_path def list_files(self) -> list: """ List all markdown files in the system. Returns: List of dictionaries containing file metadata including filename, size, and modification date information. """ # Get files from database db_files = self.db_manager.list_markdown_files() # Enhance with file system information enhanced_files = [] for file_info in db_files: enhanced_info = { 'filename': file_info['filename'], 'id': file_info['id'], 'created_at': file_info['created_at'], 'front_matter': file_info['front_matter'] } # Try to get file system stats if file exists try: file_path = Path(file_info['filename']) if file_path.exists(): stat = file_path.stat() enhanced_info['size'] = f"{stat.st_size} bytes" enhanced_info['modified'] = stat.st_mtime else: enhanced_info['size'] = 'unknown' enhanced_info['modified'] = 'file not found' except Exception: enhanced_info['size'] = 'unknown' enhanced_info['modified'] = 'unknown' enhanced_files.append(enhanced_info) return enhanced_files def get_file(self, file_path: str) -> Dict[str, Any]: """ Retrieve a markdown file from the database. Args: file_path: Path to the markdown file to retrieve Returns: Dictionary containing file content and metadata Raises: FileNotFoundError: If file is not found in database """ if not self.db_manager: raise ValueError("Database manager not initialized") # Get file from database file_data = self.db_manager.get_markdown_file(file_path) if file_data is None: raise FileNotFoundError(f"File '{file_path}' not found in database") return { 'content': file_data.get('content', ''), 'metadata': { 'filename': file_data.get('filename', file_path), 'front_matter': file_data.get('front_matter'), 'size': len(file_data.get('content', '')), 'modified': file_data.get('modified') } } def render_file(self, input_file: str, output_file: str, template: str = None, css: str = None, edit_mode: bool = False, editor_theme: str = 'github', keyboard_shortcuts: bool = True) -> Dict[str, Any]: """ Render a markdown file to HTML with client-side rendering capabilities. Creates an HTML file with embedded markdown content that is rendered client-side using JavaScript markdown parser. Args: input_file: Path to input markdown file output_file: Path to output HTML file template: Template to use (optional) css: CSS file to include (optional) edit_mode: Enable interactive edit mode (default: False) editor_theme: Editor theme (default: 'github') keyboard_shortcuts: Enable keyboard shortcuts (default: True) Returns: Dictionary with rendering results and metadata Raises: FileNotFoundError: If input file doesn't exist """ import json input_path = Path(input_file) output_path = Path(output_file) # Validate input file exists if not input_path.exists(): raise FileNotFoundError(f"Input file not found: {input_path}") # Read markdown content markdown_content = input_path.read_text(encoding='utf-8') # Extract title from markdown (first h1 heading) title = self._extract_title_from_markdown(markdown_content) # Generate HTML content html_content = self._generate_html_template( markdown_content=markdown_content, title=title, css=css, template=template, edit_mode=edit_mode, editor_theme=editor_theme, keyboard_shortcuts=keyboard_shortcuts ) # Write HTML file output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(html_content, encoding='utf-8') return { 'input_file': str(input_path), 'output_file': str(output_path), 'title': title, 'template': template, 'css': css } def _extract_title_from_markdown(self, content: str) -> str: """Extract title from markdown content (first h1 heading).""" import re # Look for first h1 heading match = re.search(r'^#\s+(.+)$', content, re.MULTILINE) if match: return match.group(1).strip() return "Markdown Document" def _generate_html_template(self, markdown_content: str, title: str, css: str = None, template: str = None, edit_mode: bool = False, editor_theme: str = 'github', keyboard_shortcuts: bool = True) -> str: """Generate HTML template with embedded markdown and client-side rendering.""" import json from pathlib import Path # Escape the markdown content for JavaScript js_markdown_content = json.dumps(markdown_content) # Clean mode only - no utility functions needed # Handle CSS styles css_content = "" if css: # Try to read CSS file content and embed it try: css_path = Path(css) if css_path.exists(): css_file_content = css_path.read_text(encoding='utf-8') css_content = f"" else: # Fallback to link if file doesn't exist css_content = f'' except Exception: # Fallback to link on any error css_content = f'' # Get template-specific CSS template_css = self._get_template_css(template) # Default CSS for basic styling default_css = f""" """ # Add editor-specific content if in edit mode editor_scripts = "" editor_config = "" editor_css = "" body_classes = "" if edit_mode: body_classes = ' class="markitect-edit-mode"' if edit_type == 'clean': # Load clean editor architecture editor_css = "" else: # Legacy editor CSS editor_css = """ """ if edit_type == 'clean': # Load the clean editor architecture try: with open('/home/worsch/markitect_project/src/section_editor.js', 'r') as f: section_editor_js = f.read() with open('/home/worsch/markitect_project/src/dom_renderer.js', 'r') as f: dom_renderer_js = f.read() with open('/home/worsch/markitect_project/src/clean_editor_integration.js', 'r') as f: clean_integration_js = f.read() except FileNotFoundError as e: print(f"Warning: Clean editor files not found: {e}") section_editor_js = "// Clean editor files not found" dom_renderer_js = "" clean_integration_js = "" # Escape the markdown content for JavaScript escaped_markdown = markdown_content.replace('\\', '\\\\').replace('`', '\\`').replace('${', '\\${') editor_config = f""" const MARKITECT_EDIT_MODE = true; const MARKITECT_EDITOR_CONFIG = {{ theme: '{editor_theme}', keyboardShortcuts: {str(keyboard_shortcuts).lower()}, autosave: false, sections: true }}; // Clean Editor Architecture {section_editor_js} {dom_renderer_js} {clean_integration_js} // Initialize the clean editor system let markitectCleanEditor; function initializeCleanEditor() {{ const container = document.getElementById('markdown-content'); if (!container) {{ console.error('Markdown content container not found'); return; }} const markdownContent = `{escaped_markdown}`; // Create the clean editor markitectCleanEditor = new MarkitectEditor.MarkitectCleanEditor(markdownContent, container, {{ theme: '{editor_theme}', keyboardShortcuts: {str(keyboard_shortcuts).lower()}, autosave: false }}); // Add control panel markitectCleanEditor.addControlPanel(); console.log('✅ Clean section editor initialized successfully'); }} function getCleanEditorMarkdown() {{ return markitectCleanEditor ? markitectCleanEditor.getDocumentMarkdown() : ''; }} function resetAllSections() {{ if (markitectCleanEditor) {{ markitectCleanEditor.resetAllSections(); }} }}""" else: # Legacy editor configuration editor_config = f""" const MARKITECT_EDIT_MODE = true; const MARKITECT_EDITOR_CONFIG = {{ theme: '{editor_theme}', keyboardShortcuts: {str(keyboard_shortcuts).lower()}, autosave: false, sections: true }};""" if edit_type == 'clean': # Clean editor uses minimal scripts since functionality is in the config editor_scripts = """ // Clean editor initialization handled in editor_config above // No additional scripts needed""" else: # Legacy editor scripts editor_scripts = """ // Legacy editor scripts // All functionality provided by the legacy editor system initializeEditor() { // Control panel is already in HTML, just make content editable this.makeContentEditable(); // Auto-expand control panel briefly to show it's available setTimeout(() => { const panel = document.getElementById('markitect-control-panel'); if (panel) { panel.classList.add('expanded'); setTimeout(() => { panel.classList.remove('expanded'); }, 2000); // Show for 2 seconds then minimize } }, 1000); } makeContentEditable() { const content = document.getElementById('markdown-content'); if (content) { content.addEventListener('click', this.handleSectionClick.bind(this)); content.addEventListener('contextmenu', this.handleSectionContextMenu.bind(this)); this.markSections(content); } } markSections(element) { // Clear existing section markers (except edited ones) const existingSections = element.querySelectorAll('.markitect-section-editable:not([data-edited])'); existingSections.forEach(section => { section.classList.remove('markitect-section-editable'); section.removeAttribute('data-section'); }); // Mark new sections (skip elements inside edited wrappers) const sections = element.querySelectorAll('h1, h2, h3, h4, h5, h6, p, blockquote, pre, ul, ol'); sections.forEach((section, index) => { // Skip if this element is inside an edited wrapper if (section.closest('[data-edited]')) { return; } // Skip if already marked as edited wrapper if (section.hasAttribute('data-edited')) { return; } section.classList.add('markitect-section-editable'); // Use stable section ID based on content hash and position to prevent re-indexing issues const stableSectionId = this.generateStableSectionId(section, index); section.setAttribute('data-section', stableSectionId); // Store original markdown for this specific section if not already stored if (!this.originalMarkdownMap.has(stableSectionId)) { const originalMarkdown = this.extractOriginalMarkdownForElement(section, index); if (originalMarkdown) { this.originalMarkdownMap.set(stableSectionId, originalMarkdown); console.log(`📝 Stored original markdown for section ${stableSectionId}: "${originalMarkdown.substring(0, 50)}..."`); } } }); } generateStableSectionId(element, index) { // Generate a stable section ID that won't change when sections are re-marked const elementType = element.tagName.toLowerCase(); const elementText = element.textContent.trim().substring(0, 50); // Create a simple hash from element content and type let hash = 0; const str = elementType + elementText + index; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } return `section_${Math.abs(hash)}_${index}`; } extractOriginalMarkdownForElement(element, sectionIndex) { // Try to extract original markdown content for a specific rendered element // by matching it to the original markdown content structure try { const elementType = element.tagName.toLowerCase(); const elementText = element.textContent.trim(); // Parse original markdown to find matching content const lines = markdownContent.split('\\n'); if (elementType.startsWith('h')) { // For headings, find matching heading text const headingLevel = parseInt(elementType.charAt(1)); const headingPrefix = '#'.repeat(headingLevel) + ' '; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line.startsWith(headingPrefix) && line.substring(headingPrefix.length).trim() === elementText) { return line; } } } else if (elementType === 'p') { // For paragraphs, find matching paragraph content for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); // Skip headings and empty lines if (!line || line.startsWith('#')) continue; // Check if this line matches the paragraph text (allowing for markdown formatting) const cleanLine = line.replace(/\\*\\*(.*?)\\*\\*/g, '$1').replace(/\\*(.*?)\\*/g, '$1').replace(/`(.*?)`/g, '$1').trim(); if (cleanLine === elementText || line === elementText) { return line; } } } // Fallback: try to find any line that contains the element text for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line && line.includes(elementText)) { return line; } } return null; } catch (error) { console.warn(`Failed to extract original markdown for section ${sectionIndex}:`, error); return null; } } handleSectionClick(event) { const section = event.target.closest('.markitect-section-editable'); if (section && !section.querySelector('textarea')) { // First, close any other open textareas to prevent content bleeding this.closeAllTextareas(); this.editSection(section); } } closeAllTextareas() { // Find and properly close all open textareas, preserving their content const allTextareas = document.querySelectorAll('.markitect-section-editable textarea'); console.log(`🔍 Found ${allTextareas.length} open textareas to close while preserving content`); allTextareas.forEach((textarea, index) => { const parentSection = textarea.closest('.markitect-section-editable'); const sectionId = parentSection ? parentSection.getAttribute('data-section') : 'unknown'; console.log(`🔄 Closing textarea ${index} (section ${sectionId}) while preserving content`); // Preserve the textarea content instead of canceling if (parentSection && sectionId) { this.preserveSectionEdit(parentSection, sectionId, textarea); } }); } preserveSectionEdit(section, sectionId, textarea) { // Preserve textarea content as a temporary edit without full save process console.log(`💾 Preserving edit for section ${sectionId}`); const content = textarea.value.trim(); if (!content) { // If textarea is empty, restore original content this.cancelSectionEditSilent(section, sectionId); return; } // Store the current edit state temporarily if (!this.tempEditMap) { this.tempEditMap = new Map(); } this.tempEditMap.set(sectionId, content); // Create a preview wrapper showing the current edit const wrapper = document.createElement('div'); wrapper.innerHTML = marked.parse(content); wrapper.classList.add('markitect-section-editable'); wrapper.setAttribute('data-section', sectionId); wrapper.setAttribute('data-temp-edit', 'true'); // Mark as temporary edit wrapper.style.backgroundColor = 'rgba(255, 235, 59, 0.1)'; // Light yellow background to indicate edit section.parentNode.replaceChild(wrapper, section); console.log(`💾 Preserved edit for section ${sectionId}: "${content.substring(0, 50)}${content.length > 50 ? '...' : ''}"`); } cancelSectionEditSilent(section, sectionId) { // Cancel editing and restore original content without triggering markSections console.log(`🔇 Silently canceling edit for section ${sectionId}`); const originalMarkdown = this.originalMarkdownMap.get(sectionId); if (originalMarkdown) { // Restore to original markdown content const wrapper = document.createElement('div'); wrapper.innerHTML = marked.parse(originalMarkdown); wrapper.classList.add('markitect-section-editable'); wrapper.setAttribute('data-section', sectionId); // Remove data-edited to show it's back to original wrapper.removeAttribute('data-edited'); section.parentNode.replaceChild(wrapper, section); console.log(`🔇 Silently restored section ${sectionId} to original content`); } else { console.warn(`⚠️ No original markdown found for section ${sectionId}, removing section`); // Fallback: just remove the section if we can't restore it if (section.parentNode) { section.parentNode.removeChild(section); } } } saveTextareaContent(textarea) { // Manually trigger the save logic that would normally happen on blur const parentSection = textarea.closest('.markitect-section-editable'); if (!parentSection) return; const content = textarea.value.trim(); const paragraphs = content.split(/\\n\\s*\\n/).filter(p => p.trim()); const sectionId = parentSection.getAttribute('data-section'); console.log(`💾 Manually saving content for section ${sectionId}: "${content}"`); if (paragraphs.length > 1) { // Multiple paragraphs - create separate sections const nextSibling = parentSection.nextSibling; parentSection.parentNode.removeChild(parentSection); paragraphs.forEach((paragraph, index) => { const wrapper = document.createElement('div'); wrapper.innerHTML = marked.parse(paragraph.trim()); wrapper.classList.add('markitect-section-editable'); wrapper.setAttribute('data-section', sectionId + '_split_' + index); wrapper.setAttribute('data-edited', 'true'); parentSection.parentNode.insertBefore(wrapper, nextSibling); }); } else { // Single content block const wrapper = document.createElement('div'); wrapper.innerHTML = marked.parse(content); wrapper.classList.add('markitect-section-editable'); wrapper.setAttribute('data-section', sectionId); wrapper.setAttribute('data-edited', 'true'); parentSection.parentNode.replaceChild(wrapper, parentSection); } this.hasEdits = true; // DON'T call markSections here - it causes re-indexing and content bleeding // this.markSections(document.getElementById('markdown-content')); this.updateSaveStatus(); } handleSectionContextMenu(event) { const section = event.target.closest('.markitect-section-editable'); if (section) { event.preventDefault(); const sectionId = section.getAttribute('data-section'); const originalMarkdown = this.originalMarkdownMap.get(sectionId); if (originalMarkdown) { const menu = [ 'Reset this section to original?', '', 'Original content:', originalMarkdown.length > 50 ? originalMarkdown.substring(0, 50) + '...' : originalMarkdown ].join('\\n'); if (confirm(menu)) { this.resetSectionToOriginal(sectionId); } } } } editSection(section) { const sectionId = section.getAttribute('data-section'); console.log(`📝 Starting edit for section ${sectionId}`); // Create a completely fresh textarea const textarea = document.createElement('textarea'); textarea.className = 'edit-mode'; // Check for temporary edits first, then original markdown, then HTML conversion const originalMarkdown = this.originalMarkdownMap.get(sectionId); const tempEdit = this.tempEditMap ? this.tempEditMap.get(sectionId) : null; const isEditedSection = section.hasAttribute('data-edited'); const isTempEdit = section.hasAttribute('data-temp-edit'); if (tempEdit && isTempEdit) { // Restore temporary edit content textarea.value = tempEdit; console.log(`🔄 Restoring temporary edit for section ${sectionId}: "${tempEdit.substring(0, 100)}${tempEdit.length > 100 ? '...' : ''}"`); } else if (originalMarkdown) { // Use original markdown when available (for both edited and unedited sections) textarea.value = originalMarkdown; console.log(`🔄 Using original markdown for section ${sectionId}: "${originalMarkdown.substring(0, 100)}${originalMarkdown.length > 100 ? '...' : ''}"`); } else { // For sections without original markdown, convert from current HTML const currentHTML = section.innerHTML; const convertedContent = this.htmlToMarkdown(currentHTML); textarea.value = convertedContent; console.log(`⚠️ Converting from current HTML for section ${sectionId} (edited: ${isEditedSection}): "${convertedContent.substring(0, 100)}${convertedContent.length > 100 ? '...' : ''}"`); console.log(` Source HTML was: "${currentHTML.substring(0, 100)}${currentHTML.length > 100 ? '...' : ''}"`); } // Ensure textarea value is properly set and prevent any bleeding const finalValue = textarea.value || ''; textarea.value = finalValue; textarea.defaultValue = finalValue; console.log(`✅ Final textarea value for section ${sectionId}: "${finalValue.substring(0, 100)}${finalValue.length > 100 ? '...' : ''}"`); // Verify no other textareas exist const existingTextareas = document.querySelectorAll('.markitect-section-editable textarea'); if (existingTextareas.length > 0) { console.warn(`⚠️ Found ${existingTextareas.length} existing textareas when starting new edit!`); } // Get original element font size and style const computedStyle = window.getComputedStyle(section); const originalFontSize = computedStyle.fontSize; const originalLineHeight = computedStyle.lineHeight; // Apply matching font size to textarea textarea.style.fontSize = originalFontSize; if (originalLineHeight !== 'normal') { textarea.style.lineHeight = originalLineHeight; } // Auto-sizing function const autoResize = () => { // Temporarily disable transition for accurate measurement const transition = textarea.style.transition; textarea.style.transition = 'none'; // Reset height to measure scrollHeight textarea.style.height = 'auto'; // Calculate based on actual content with more reasonable constraints const contentHeight = textarea.scrollHeight; const padding = 24; // 12px top + 12px bottom // More reasonable sizing: min 2 lines, max 15 lines const lineCount = textarea.value.split('\\n').length; const minHeight = Math.max(60, lineCount * 24 + padding); // ~24px per line const maxHeight = 360; // Maximum height constraint const newHeight = Math.max(60, Math.min(maxHeight, Math.max(minHeight, contentHeight + 4))); textarea.style.height = newHeight + 'px'; // Re-enable transition textarea.style.transition = transition; }; // Auto-resize on input and paste textarea.addEventListener('input', autoResize); textarea.addEventListener('paste', () => setTimeout(autoResize, 10)); // Initial sizing after DOM update setTimeout(autoResize, 20); // Note: Removed automatic blur handler that was causing content bleeding // Content saving is now handled explicitly through the Accept button // Create section controls const controls = document.createElement('div'); controls.className = 'markitect-section-controls'; const acceptBtn = document.createElement('button'); acceptBtn.className = 'markitect-section-btn accept'; acceptBtn.innerHTML = '✓ Accept'; acceptBtn.title = 'Accept changes and save this section'; const cancelBtn = document.createElement('button'); cancelBtn.className = 'markitect-section-btn cancel'; cancelBtn.innerHTML = '✗ Cancel'; cancelBtn.title = 'Cancel editing and revert to original'; const resetBtn = document.createElement('button'); resetBtn.className = 'markitect-section-btn reset'; resetBtn.innerHTML = '🔄 Reset'; resetBtn.title = 'Reset to original markdown content'; // Add event listeners acceptBtn.addEventListener('click', () => { this.acceptSectionEdit(section, textarea); }); cancelBtn.addEventListener('click', () => { this.cancelSectionEdit(section, sectionId); }); resetBtn.addEventListener('click', () => { this.resetSectionEdit(section, sectionId, textarea); }); controls.appendChild(acceptBtn); controls.appendChild(cancelBtn); controls.appendChild(resetBtn); // Create the new layout structure const editContainer = document.createElement('div'); editContainer.className = 'markitect-edit-container'; const textareaWrapper = document.createElement('div'); textareaWrapper.className = 'markitect-textarea-wrapper'; textareaWrapper.appendChild(textarea); editContainer.appendChild(textareaWrapper); editContainer.appendChild(controls); // Completely clear the section and replace with the new layout section.innerHTML = ''; section.appendChild(editContainer); // Focus and ensure cursor is at start textarea.focus(); textarea.setSelectionRange(0, 0); } htmlToMarkdown(html) { // Create a temporary element to parse the HTML const temp = document.createElement('div'); temp.innerHTML = html; // Better HTML to Markdown conversion that preserves structure let markdown = ''; const processNode = (node) => { if (node.nodeType === Node.TEXT_NODE) { return node.textContent; } if (node.nodeType === Node.ELEMENT_NODE) { const tagName = node.tagName.toLowerCase(); switch (tagName) { case 'h1': return '# ' + node.textContent.trim(); case 'h2': return '## ' + node.textContent.trim(); case 'h3': return '### ' + node.textContent.trim(); case 'h4': return '#### ' + node.textContent.trim(); case 'h5': return '##### ' + node.textContent.trim(); case 'h6': return '###### ' + node.textContent.trim(); case 'p': // Handle paragraphs with potential inline formatting const childText = Array.from(node.childNodes).map(processNode).join(''); return childText; case 'strong': case 'b': return '**' + node.textContent + '**'; case 'em': case 'i': return '*' + node.textContent + '*'; case 'code': return '`' + node.textContent + '`'; case 'pre': // Handle code blocks const codeContent = node.textContent; return '```\\n' + codeContent + '\\n```'; case 'blockquote': const quoteLines = node.textContent.split('\\n'); return quoteLines.map(line => '> ' + line).join('\\n'); case 'ul': // Handle unordered lists const ulItems = Array.from(node.querySelectorAll('li')); return ulItems.map(li => '- ' + li.textContent).join('\\n'); case 'ol': // Handle ordered lists const olItems = Array.from(node.querySelectorAll('li')); return olItems.map((li, index) => (index + 1) + '. ' + li.textContent).join('\\n'); case 'br': return '\\n'; default: return node.textContent; } } return ''; }; // Process each child node and add appropriate spacing const nodes = Array.from(temp.childNodes).filter(node => node.nodeType === Node.ELEMENT_NODE || (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) ); nodes.forEach((node, index) => { const result = processNode(node); if (result.trim()) { if (index > 0 && markdown.trim()) { markdown += '\\n\\n'; } markdown += result; } }); return markdown.trim(); } parseOriginalMarkdown() { // Initialize the original markdown map - actual mapping happens in markSections // to ensure alignment between HTML elements and markdown content console.log('📝 Initializing original markdown mapping system'); // The actual mapping happens during markSections() to ensure HTML-markdown alignment } acceptSectionEdit(section, textarea) { const sectionId = section.getAttribute('data-section'); console.log(`✅ Accepting edit for section ${sectionId}`); // Clear any temporary edit for this section if (this.tempEditMap && this.tempEditMap.has(sectionId)) { this.tempEditMap.delete(sectionId); console.log(`🗑️ Cleared temporary edit for section ${sectionId}`); } // Manually trigger the save logic this.saveTextareaContent(textarea); } cancelSectionEdit(section, sectionId) { console.log(`❌ Canceling edit for section ${sectionId}`); // Clear any temporary edit for this section if (this.tempEditMap && this.tempEditMap.has(sectionId)) { this.tempEditMap.delete(sectionId); console.log(`🗑️ Cleared temporary edit for section ${sectionId}`); } // Restore the original content without saving const originalMarkdown = this.originalMarkdownMap.get(sectionId); if (originalMarkdown) { // Restore to original markdown content const wrapper = document.createElement('div'); wrapper.innerHTML = marked.parse(originalMarkdown); wrapper.classList.add('markitect-section-editable'); wrapper.setAttribute('data-section', sectionId); // Remove data-edited and data-temp-edit to show it's back to original wrapper.removeAttribute('data-edited'); wrapper.removeAttribute('data-temp-edit'); section.parentNode.replaceChild(wrapper, section); } else { console.warn(`⚠️ No original markdown found for section ${sectionId}`); // Fallback: just remove the editing interface if (section.parentNode) { section.parentNode.removeChild(section); } } // DON'T call markSections - it causes re-indexing issues // this.markSections(document.getElementById('markdown-content')); this.updateSaveStatus(); } resetSectionEdit(section, sectionId, textarea) { console.log(`🔄 Resetting edit for section ${sectionId}`); // Reset textarea content to original markdown const originalMarkdown = this.originalMarkdownMap.get(sectionId); if (originalMarkdown) { textarea.value = originalMarkdown; console.log(`🔄 Reset textarea content to: "${originalMarkdown}"`); } else { console.warn(`⚠️ No original markdown found for section ${sectionId}`); } } setupKeyboardShortcuts() { if (MARKITECT_EDITOR_CONFIG.keyboardShortcuts) { document.addEventListener('keydown', (event) => { if (event.ctrlKey || event.metaKey) { switch(event.key) { case 's': event.preventDefault(); this.save(); break; case 'e': event.preventDefault(); this.togglePreview(); break; case 'r': event.preventDefault(); this.resetToOriginal(); break; } } }); } } save() { try { // Get the current markdown content from the editor const markdownContent = this.getMarkdownContent(); // Create filename with timestamp suffix for backup convention const now = new Date(); const timestamp = now.toISOString().slice(0, 19).replace(/:/g, '-').replace('T', '-'); const originalFilename = window.location.pathname.split('/').pop().replace('.html', '.md'); const backupFilename = `${originalFilename.replace('.md', '')}-edited-${timestamp}.md`; // Create and download the file const blob = new Blob([markdownContent], { type: 'text/markdown' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = backupFilename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); // Update status with filename convention info const statusEl = document.getElementById('save-status'); statusEl.textContent = `Downloaded: ${backupFilename}`; statusEl.title = 'File saved with timestamp to avoid overwriting original'; setTimeout(() => { this.updateSaveStatus(); }, 5000); } catch (error) { document.getElementById('save-status').textContent = 'Save failed!'; console.error('Save error:', error); setTimeout(() => { this.updateSaveStatus(); }, 3000); } } updateSaveStatus() { const editedSections = document.querySelectorAll('[data-edited]').length; const totalSections = document.querySelectorAll('.markitect-section-editable').length; const statusEl = document.getElementById('save-status'); if (editedSections === 0) { statusEl.textContent = 'Ready to save'; statusEl.title = ''; } else { statusEl.textContent = `Ready (${editedSections}/${totalSections} sections edited)`; statusEl.title = 'Some sections have been modified from original'; } } getMarkdownContent() { // If no edits have been made, return the original markdown content if (!this.hasEdits) { return markdownContent; } // Reconstruct markdown content from the current state of sections const content = document.getElementById('markdown-content'); if (!content) { return markdownContent; // fallback to original } // Simple approach: get the text content and convert back to markdown // This is a basic implementation - could be enhanced for better preservation const sections = content.querySelectorAll('.markitect-section-editable'); let reconstructed = ''; sections.forEach(section => {{ // Handle edited wrappers differently if (section.hasAttribute('data-edited')) {{ // For edited sections, convert the child elements back to markdown const childElements = section.children; for (let i = 0; i < childElements.length; i++) {{ const child = childElements[i]; const tagName = child.tagName.toLowerCase(); const text = child.textContent.trim(); if (tagName.startsWith('h')) {{ const level = parseInt(tagName.charAt(1)); reconstructed += '#'.repeat(level) + ' ' + text + '\\n\\n'; }} else if (tagName === 'p') {{ reconstructed += text + '\\n\\n'; }} else if (tagName === 'blockquote') {{ reconstructed += '> ' + text + '\\n\\n'; }} else if (tagName === 'pre') {{ reconstructed += '```\\n' + text + '\\n```\\n\\n'; }} else if (tagName === 'ul') {{ const items = child.querySelectorAll('li'); items.forEach(item => {{ reconstructed += '- ' + item.textContent.trim() + '\\n'; }}); reconstructed += '\\n'; }} else if (tagName === 'ol') {{ const items = child.querySelectorAll('li'); items.forEach((item, index) => {{ reconstructed += (index + 1) + '. ' + item.textContent.trim() + '\\n'; }}); reconstructed += '\\n'; }} else {{ reconstructed += text + '\\n\\n'; }} }} }} else {{ // Handle regular sections const tagName = section.tagName.toLowerCase(); const text = section.textContent.trim(); if (tagName.startsWith('h')) {{ const level = parseInt(tagName.charAt(1)); reconstructed += '#'.repeat(level) + ' ' + text + '\\n\\n'; }} else if (tagName === 'p') {{ reconstructed += text + '\\n\\n'; }} else if (tagName === 'blockquote') {{ reconstructed += '> ' + text + '\\n\\n'; }} else if (tagName === 'pre') {{ reconstructed += '```\\n' + text + '\\n```\\n\\n'; }} else if (tagName === 'ul') {{ const items = section.querySelectorAll('li'); items.forEach(item => {{ reconstructed += '- ' + item.textContent.trim() + '\\n'; }}); reconstructed += '\\n'; }} else if (tagName === 'ol') {{ const items = section.querySelectorAll('li'); items.forEach((item, index) => {{ reconstructed += (index + 1) + '. ' + item.textContent.trim() + '\\n'; }}); reconstructed += '\\n'; }} else {{ reconstructed += text + '\\n\\n'; }} }} }}); return reconstructed.trim(); } togglePreview() { console.log('Toggle preview mode'); } resetToOriginal() { if (confirm('Reset all content to original markdown? This will lose all edits.')) { // Clear all edits and reload original content const content = document.getElementById('markdown-content'); if (content && typeof marked !== 'undefined') { content.innerHTML = marked.parse(markdownContent); this.hasEdits = false; this.markSections(content); console.log('🔄 Reset to original content'); } } } resetSectionToOriginal(sectionId) { const originalMarkdown = this.originalMarkdownMap.get(sectionId); if (originalMarkdown) { // Find the section and reset it const section = document.querySelector(`[data-section="${sectionId}"]`); if (section && typeof marked !== 'undefined') { const wrapper = document.createElement('div'); wrapper.innerHTML = marked.parse(originalMarkdown); wrapper.classList.add('markitect-section-editable'); wrapper.setAttribute('data-section', sectionId); // Remove data-edited to show it's back to original wrapper.removeAttribute('data-edited'); section.parentNode.replaceChild(wrapper, section); this.markSections(document.getElementById('markdown-content')); this.updateSaveStatus(); console.log(`🔄 Reset section ${sectionId} to original`); } } } } let markitectEditor; // Control panel toggle functionality function toggleControlPanel() { const panel = document.getElementById('markitect-control-panel'); if (panel) { panel.classList.toggle('expanded'); } } // Auto-close panel when clicking outside document.addEventListener('click', function(event) { const panel = document.getElementById('markitect-control-panel'); if (panel && panel.classList.contains('expanded')) { if (!panel.contains(event.target)) { panel.classList.remove('expanded'); } } // Legacy editor architecture loaded above """ # Clean mode doesn't need legacy control panel edit_mode_html = "" version = "0.3.0" # fallback try: from importlib.metadata import version as get_version version = get_version('markitect') except: pass # Get git commit with timestamp and local changes info git_info = "" try: repo_path = Path(__file__).parent.parent # Get commit hash and timestamp result = subprocess.run(['git', 'rev-parse', '--short', 'HEAD'], capture_output=True, text=True, cwd=repo_path) if result.returncode == 0: commit_hash = result.stdout.strip() # Get commit timestamp timestamp_result = subprocess.run(['git', 'show', '-s', '--format=%ci', 'HEAD'], capture_output=True, text=True, cwd=repo_path) commit_time = "" if timestamp_result.returncode == 0: from datetime import datetime # Parse git timestamp and format it nicely git_time = timestamp_result.stdout.strip() try: dt = datetime.fromisoformat(git_time.replace(' +', '+')) commit_time = f" ({dt.strftime('%Y-%m-%d %H:%M')})" except: pass git_info = f"+{commit_hash}{commit_time}" # Check for uncommitted changes status_result = subprocess.run(['git', 'status', '--porcelain'], capture_output=True, text=True, cwd=repo_path) if status_result.returncode == 0 and status_result.stdout.strip(): # Get timestamp of most recent uncommitted change import os import glob latest_change = 0 for line in status_result.stdout.strip().split('\n'): if line.strip(): # Extract filename (skip first 3 chars which are status indicators) filename = line[3:].strip() try: file_path = repo_path / filename if file_path.exists(): mtime = os.path.getmtime(file_path) latest_change = max(latest_change, mtime) except: pass if latest_change > 0: change_dt = datetime.fromtimestamp(latest_change) git_info += f" including local changes until {change_dt.strftime('%Y-%m-%d %H:%M')}" except: pass version_info = f"{version}{git_info}" except: version_info = "0.3.0" edit_mode_html = f"""
v{version_info}