From be763c6504b5b7d991f834eb1ca02357fbce026a Mon Sep 17 00:00:00 2001 From: Alexopus Date: Fri, 18 Jul 2025 16:15:48 +0200 Subject: [PATCH] first --- .gitignore | 1 + CLAUDE.md | 94 +++++++++++ README.md | 3 + __init__.py | 79 +++++++++ js/extension.js | 380 +++++++++++++++++++++++++++++++++++++++++++ js/notes-sidebar.css | 119 ++++++++++++++ pyproject.toml | 17 ++ 7 files changed, 693 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 __init__.py create mode 100644 js/extension.js create mode 100644 js/notes-sidebar.css create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed8ebf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3c727ab --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,94 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a ComfyUI extension that adds a server-side notes sidebar for managing markdown notes. The extension provides a complete notes management system with both backend API endpoints and a frontend JavaScript interface. + +## Architecture + +### Backend (Python) +- **Main Module**: `__init__.py` - Core extension entry point +- **NotesManager Class**: Handles all note operations (CRUD) with file system storage +- **API Endpoints**: RESTful endpoints using aiohttp web framework integrated with ComfyUI's PromptServer +- **File Storage**: Notes stored as `.md` files in the `notes/` directory + +### Frontend (JavaScript) +- **Extension Module**: `js/extension.js` - ComfyUI extension integration +- **NotesSidebar Class**: Main UI component managing tabs, content area, and user interactions +- **Styling**: `js/notes-sidebar.css` - Complete UI styling for dark theme integration + +### Key Components + +1. **Server Integration**: Uses ComfyUI's PromptServer.instance.routes for API endpoints +2. **File Management**: Notes stored as markdown files with automatic directory creation +3. **UI Architecture**: Tab-based interface with content area and action buttons +4. **Extension Registration**: Properly registers with ComfyUI's extension manager as a sidebar tab +5. **State Management**: Three Maps track content state: + - `noteContent`: Current content for each note (including unsaved) + - `savedContent`: Last saved content from server + - `unsavedChanges`: Boolean flag for each note's modification state + +## API Endpoints + +The extension exposes these REST endpoints: +- `GET /notes_sidebar/list` - List all notes +- `GET /notes_sidebar/note/{note_name}` - Get specific note content +- `POST /notes_sidebar/note/{note_name}` - Save/create note +- `DELETE /notes_sidebar/note/{note_name}` - Delete note + +## Development Notes + +### Extension Structure +- Must export `NODE_CLASS_MAPPINGS`, `NODE_DISPLAY_NAME_MAPPINGS`, and `WEB_DIRECTORY` for ComfyUI compatibility +- JavaScript extension uses ComfyUI's `app.registerExtension()` pattern +- Sidebar integration uses `app.extensionManager.registerSidebarTab()` + +### File Operations +- Notes are automatically saved as `.md` files in the `notes/` directory +- File names are URL-encoded for safe handling +- Directory creation is handled automatically on initialization + +### UI State Management +- Active note tracking with tab highlighting +- Content persistence across sidebar open/close cycles +- Real-time tab rendering on note operations +- Button layout: Save/Rename on left, Refresh/Delete on right +- **Unsaved Changes Tracking**: Visual indicators (*) for modified notes +- **Content Preservation**: Tab switching preserves unsaved content +- **Change Detection**: Automatic tracking of modifications vs saved state + +### Unsaved Changes Behavior +- Notes with modifications show asterisk (*) in tab name +- Switching tabs preserves unsaved content in inactive notes +- `onTextAreaChange()` method tracks real-time content changes +- Save operation clears unsaved state and updates saved content reference +- Delete/rename operations properly clean up tracking maps + +## Development Commands + +### Installation & Setup +- No build process required - extension loads directly in ComfyUI +- Place extension folder in ComfyUI's `custom_nodes/` directory +- Restart ComfyUI to load the extension + +### Testing the Extension +No automated tests defined. Manual testing workflow: +1. Load extension in ComfyUI +2. Verify sidebar tab appears with "Notes" label and book icon +3. Test CRUD operations: create, edit, save, rename, delete notes +4. Verify tab switching and content persistence + +### Development Workflow +1. Modify Python code in `__init__.py` for backend changes +2. Modify JavaScript in `js/extension.js` for frontend changes +3. Update CSS in `js/notes-sidebar.css` for styling changes +4. Restart ComfyUI to reload the extension (required for Python changes) +5. Refresh browser for JavaScript/CSS changes + +### File Structure +- `notes/` - Storage directory for markdown notes (auto-created) +- `js/` - Frontend assets loaded by ComfyUI +- `__init__.py` - Main extension code +- `pyproject.toml` - Project metadata \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..83258d9 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ComfyUI Notes Sidebar + +A ComfyUI extension that adds a notes sidebar for managing markdown notes on the server-side. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..d9f8711 --- /dev/null +++ b/__init__.py @@ -0,0 +1,79 @@ +import os +from aiohttp import web +from server import PromptServer + +WEB_DIRECTORY = "./js" + +# Required for ComfyUI to recognize this as a valid extension +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + +class NotesManager: + def __init__(self): + self.notes_dir = os.path.join(os.path.dirname(__file__), "notes") + os.makedirs(self.notes_dir, exist_ok=True) + + def get_notes_list(self) -> list[str]: + notes : list[str] = [] + for filename in os.listdir(self.notes_dir): + if filename.endswith('.md'): + notes.append(filename[:-3]) # Remove .md extension + return sorted(notes) + + def get_note_content(self, note_name: str): + file_path = os.path.join(self.notes_dir, f"{note_name}.md") + if os.path.exists(file_path): + with open(file_path, 'r', encoding='utf-8') as f: + return f.read() + return "" + + def save_note(self, note_name: str, content: str): + file_path = os.path.join(self.notes_dir, f"{note_name}.md") + with open(file_path, 'w', encoding='utf-8') as f: + f.write(content) + + def delete_note(self, note_name: str): + file_path = os.path.join(self.notes_dir, f"{note_name}.md") + if os.path.exists(file_path): + os.remove(file_path) + +notes_manager = NotesManager() + +@PromptServer.instance.routes.get("/notes_sidebar/list") +async def get_notes_list(request): + try: + notes = notes_manager.get_notes_list() + return web.json_response({"notes": notes}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + +@PromptServer.instance.routes.get("/notes_sidebar/note/{note_name}") +async def get_note(request): + try: + note_name = request.match_info['note_name'] + content = notes_manager.get_note_content(note_name) + return web.json_response({"content": content}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + +@PromptServer.instance.routes.post("/notes_sidebar/note/{note_name}") +async def save_note(request): + try: + note_name = request.match_info['note_name'] + data = await request.json() + content = data.get('content', '') + notes_manager.save_note(note_name, content) + return web.json_response({"success": True}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + +@PromptServer.instance.routes.delete("/notes_sidebar/note/{note_name}") +async def delete_note(request): + try: + note_name = request.match_info['note_name'] + notes_manager.delete_note(note_name) + return web.json_response({"success": True}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY'] \ No newline at end of file diff --git a/js/extension.js b/js/extension.js new file mode 100644 index 0000000..3153d77 --- /dev/null +++ b/js/extension.js @@ -0,0 +1,380 @@ +import { app } from "/scripts/app.js"; + +// Load CSS +const link = document.createElement('link'); +link.rel = 'stylesheet'; +link.href = '/extensions/comfyui-notes-sidebar/notes-sidebar.css'; +document.head.appendChild(link); + +class NotesSidebar { + constructor() { + this.notes = []; + this.activeNote = null; + this.tabsContainer = null; + this.contentArea = null; + this.textArea = null; + this.isInitialized = false; + this.sidebarElement = null; + // Track unsaved changes and content for each note + this.unsavedChanges = new Map(); // noteName -> boolean + this.noteContent = new Map(); // noteName -> string (current content) + this.savedContent = new Map(); // noteName -> string (last saved content) + } + + async init() { + await this.loadNotes(); + } + + async createSidebarContent(el) { + // Ensure the parent element has proper height + el.style.height = '100%'; + el.style.display = 'flex'; + el.style.flexDirection = 'column'; + + // If already initialized, just update the existing content + if (this.isInitialized && this.sidebarElement) { + el.innerHTML = ''; + el.appendChild(this.sidebarElement); + // Refresh the active note content when reopening + if (this.activeNote) { + this.loadNoteContent(this.activeNote); + } + return; + } + + // Create new sidebar content + this.sidebarElement = document.createElement('div'); + this.sidebarElement.className = 'notes-sidebar'; + + this.createTabsContainer(this.sidebarElement); + this.createContentArea(this.sidebarElement); + + el.appendChild(this.sidebarElement); + this.isInitialized = true; + + // Load notes after creating UI + await this.loadNotes(); + } + + createTabsContainer(parent) { + this.tabsContainer = document.createElement('div'); + this.tabsContainer.className = 'notes-tabs-container'; + + const newTabBtn = document.createElement('button'); + newTabBtn.textContent = '+'; + newTabBtn.title = 'New Note'; + newTabBtn.className = 'notes-new-tab-btn'; + newTabBtn.onclick = () => this.createNewNote(); + + this.tabsContainer.appendChild(newTabBtn); + parent.appendChild(this.tabsContainer); + } + + createContentArea(parent) { + this.contentArea = document.createElement('div'); + this.contentArea.className = 'notes-content-area'; + + this.textArea = document.createElement('textarea'); + this.textArea.className = 'notes-textarea'; + this.textArea.placeholder = 'Start typing your notes...'; + + // Add event listener to track changes + this.textArea.addEventListener('input', () => this.onTextAreaChange()); + + const buttonContainer = document.createElement('div'); + buttonContainer.className = 'notes-button-container'; + + const leftButtonContainer = document.createElement('div'); + leftButtonContainer.className = 'notes-button-group'; + + const rightButtonContainer = document.createElement('div'); + rightButtonContainer.className = 'notes-button-group'; + + const deleteBtn = document.createElement('button'); + deleteBtn.textContent = 'delete'; + deleteBtn.className = 'notes-btn delete'; + deleteBtn.onclick = () => this.deleteActiveNote(); + + const renameBtn = document.createElement('button'); + renameBtn.textContent = 'rename'; + renameBtn.className = 'notes-btn rename'; + renameBtn.onclick = () => this.renameActiveNote(); + + const saveBtn = document.createElement('button'); + saveBtn.textContent = 'save'; + saveBtn.className = 'notes-btn save'; + saveBtn.onclick = () => this.saveActiveNote(); + + const refreshBtn = document.createElement('button'); + refreshBtn.textContent = 'refresh'; + refreshBtn.className = 'notes-btn refresh'; + refreshBtn.onclick = () => this.refreshNotes(); + + leftButtonContainer.appendChild(saveBtn); + leftButtonContainer.appendChild(renameBtn); + rightButtonContainer.appendChild(refreshBtn); + rightButtonContainer.appendChild(deleteBtn); + + buttonContainer.appendChild(leftButtonContainer); + buttonContainer.appendChild(rightButtonContainer); + + this.contentArea.appendChild(this.textArea); + this.contentArea.appendChild(buttonContainer); + parent.appendChild(this.contentArea); + } + + async loadNotes() { + try { + const response = await fetch('/notes_sidebar/list'); + const data = await response.json(); + this.notes = data.notes || []; + + if (this.notes.length > 0 && !this.activeNote) { + this.activeNote = this.notes[0]; + } + + this.renderTabs(); + + // Load content for active note after rendering tabs + if (this.activeNote) { + await this.loadNoteContent(this.activeNote); + } + } catch (error) { + console.error('Error loading notes:', error); + } + } + + renderTabs() { + const existingTabs = this.tabsContainer.querySelectorAll('.note-tab'); + existingTabs.forEach(tab => tab.remove()); + + this.notes.forEach(noteName => { + const tab = document.createElement('button'); + const isUnsaved = this.unsavedChanges.get(noteName) || false; + tab.textContent = noteName + (isUnsaved ? ' *' : ''); + tab.className = `note-tab ${this.activeNote === noteName ? 'active' : ''}`; + tab.onclick = () => this.selectNote(noteName); + + this.tabsContainer.appendChild(tab); + }); + } + + async selectNote(noteName) { + // Save current content before switching + if (this.activeNote && this.textArea) { + this.noteContent.set(this.activeNote, this.textArea.value); + } + + this.activeNote = noteName; + await this.loadNoteContent(noteName); + this.renderTabs(); + } + + async loadNoteContent(noteName) { + try { + // Check if we have unsaved content for this note + if (this.noteContent.has(noteName)) { + // Use the unsaved content + if (this.textArea) { + this.textArea.value = this.noteContent.get(noteName); + } + } else { + // Load from server + const response = await fetch(`/notes_sidebar/note/${encodeURIComponent(noteName)}`); + const data = await response.json(); + const content = data.content || ''; + if (this.textArea) { + this.textArea.value = content; + } + // Store both current and saved content + this.noteContent.set(noteName, content); + this.savedContent.set(noteName, content); + } + } catch (error) { + console.error('Error loading note content:', error); + } + } + + async saveActiveNote() { + if (!this.activeNote) return; + + try { + const content = this.textArea.value; + const response = await fetch(`/notes_sidebar/note/${encodeURIComponent(this.activeNote)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', }, + body: JSON.stringify({ content: content }) + }); + + if (response.ok) { + console.log('Note saved successfully'); + // Mark as saved + this.savedContent.set(this.activeNote, content); + this.noteContent.set(this.activeNote, content); + this.unsavedChanges.set(this.activeNote, false); + this.renderTabs(); // Update tab display + } else { + console.error('Error saving note'); + } + } catch (error) { + console.error('Error saving note:', error); + } + } + + async deleteActiveNote() { + if (!this.activeNote) return; + + if (!confirm(`Are you sure you want to delete "${this.activeNote}"?`)) return; + + try { + const response = await fetch(`/notes_sidebar/note/${encodeURIComponent(this.activeNote)}`, { + method: 'DELETE' + }); + + if (response.ok) { + // Clean up tracking for deleted note + const deletedNote = this.activeNote; + this.unsavedChanges.delete(deletedNote); + this.noteContent.delete(deletedNote); + this.savedContent.delete(deletedNote); + + await this.refreshNotes(); + // Select the first available note or clear if no notes left + this.activeNote = this.notes.length > 0 ? this.notes[0] : null; + if (this.activeNote) { + await this.loadNoteContent(this.activeNote); + } else { + this.textArea.value = ''; + } + } + } catch (error) { + console.error('Error deleting note:', error); + } + } + + async renameActiveNote() { + if (!this.activeNote) return; + + const newName = prompt('Enter new note name:', this.activeNote); + if (!newName || newName === this.activeNote) return; + + try { + // Check if new name already exists + if (this.notes.includes(newName)) { + alert('A note with this name already exists!'); + return; + } + + // Get current content + const currentContent = this.textArea.value; + + // Create new note with the content + const createResponse = await fetch(`/notes_sidebar/note/${encodeURIComponent(newName)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', }, + body: JSON.stringify({ content: currentContent }) + }); + + if (createResponse.ok) { + // Delete old note + await fetch(`/notes_sidebar/note/${encodeURIComponent(this.activeNote)}`, { method: 'DELETE' }); + + // Update tracking maps + const oldName = this.activeNote; + const unsavedState = this.unsavedChanges.get(oldName) || false; + const content = this.noteContent.get(oldName) || currentContent; + + // Remove old tracking + this.unsavedChanges.delete(oldName); + this.noteContent.delete(oldName); + this.savedContent.delete(oldName); + + // Add new tracking + this.noteContent.set(newName, content); + this.savedContent.set(newName, currentContent); // The content we just saved + this.unsavedChanges.set(newName, false); // Just saved, so no unsaved changes + + // Update active note and refresh + this.activeNote = newName; + await this.refreshNotes(); + this.renderTabs(); + } + } catch (error) { + console.error('Error renaming note:', error); + } + } + + async createNewNote() { + const noteName = prompt('Enter note name:'); + if (!noteName) return; + + try { + const response = await fetch(`/notes_sidebar/note/${encodeURIComponent(noteName)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', }, + body: JSON.stringify({ content: '' }) + }); + + if (response.ok) { + await this.refreshNotes(); + this.activeNote = noteName; + this.textArea.value = ''; + + // Initialize tracking for new note + this.noteContent.set(noteName, ''); + this.savedContent.set(noteName, ''); + this.unsavedChanges.set(noteName, false); + + this.renderTabs(); + } + } catch (error) { + console.error('Error creating note:', error); + } + } + + async refreshNotes() { + // Preserve current content before refreshing + if (this.activeNote && this.textArea) { + this.noteContent.set(this.activeNote, this.textArea.value); + } + + await this.loadNotes(); + } + + onTextAreaChange() { + if (!this.activeNote) return; + + const currentContent = this.textArea.value; + const savedContent = this.savedContent.get(this.activeNote) || ''; + + // Update current content + this.noteContent.set(this.activeNote, currentContent); + + // Check if content has changed from saved version + const hasUnsavedChanges = currentContent !== savedContent; + this.unsavedChanges.set(this.activeNote, hasUnsavedChanges); + + // Update tab display + this.renderTabs(); + } +} + +const notesSidebar = new NotesSidebar(); + +app.registerExtension({ + name: "comfyui.notes.sidebar", + async setup() { + await notesSidebar.init(); + + app.extensionManager.registerSidebarTab({ + id: "notes", + icon: "pi pi-book", + title: "Notes", + tooltip: "Notes", + type: "custom", + render: (el) => { + notesSidebar.createSidebarContent(el); + }, + }); + } +}); diff --git a/js/notes-sidebar.css b/js/notes-sidebar.css new file mode 100644 index 0000000..d6ca723 --- /dev/null +++ b/js/notes-sidebar.css @@ -0,0 +1,119 @@ +/* Notes Sidebar Styles */ + +.notes-sidebar { + display: flex; + flex-direction: column; + height: 100%; + font-family: Arial, sans-serif; + color: #fff; +} + +.notes-tabs-container { + display: flex; + flex-wrap: wrap; + gap: 2px; + padding: 10px; + background: #1a1a1a; + border-bottom: 1px solid #444; + min-height: 40px; +} + +.notes-new-tab-btn { + background: #444; + border: none; + color: #fff; + padding: 5px 10px; + cursor: pointer; + border-radius: 3px; + font-size: 16px; +} + +.notes-new-tab-btn:hover { + background: #555; +} + +.note-tab { + background: #444; + border: none; + color: #fff; + padding: 5px 10px; + cursor: pointer; + border-radius: 3px; + font-size: 12px; + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.note-tab:hover { + background: #555; +} + +.note-tab.active { + background: #4CAF50; +} + +.notes-content-area { + flex: 1; + display: flex; + flex-direction: column; + padding: 15px; + gap: 10px; +} + +.notes-textarea { + flex: 1; + background: #1a1a1a; + border: 1px solid #444; + color: #fff; + padding: 10px; + font-family: monospace; + font-size: 14px; + resize: none; + outline: none; +} + +.notes-textarea:focus { + border-color: #4CAF50; +} + +.notes-button-container { + display: flex; + gap: 10px; + justify-content: space-between; +} + +.notes-button-group { + display: flex; + gap: 10px; +} + +.notes-btn { + border: none; + color: white; + padding: 8px 16px; + cursor: pointer; + border-radius: 4px; + font-size: 14px; +} + +.notes-btn:hover { + opacity: 0.8; +} + +.notes-btn.delete { + background: #ff4444; +} + +.notes-btn.save { + background: #4CAF50; +} + +.notes-btn.refresh { + background: #2196F3; +} + +.notes-btn.rename { + background: #FF9800; +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6701f0d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "comfyui-notes-sidebar" +version = "1.0.0" +description = "A ComfyUI extension that adds a notes sidebar for managing markdown notes" +authors = [ + {name = "ComfyUI Notes Extension", email = "example@email.com"} +] +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.7" + +[project.urls] +Repository = "https://github.com/your-username/comfyui-notes-sidebar" + +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" \ No newline at end of file