diff --git a/autonotes.py b/autonotes.py index 19ae2b8..90fa498 100644 --- a/autonotes.py +++ b/autonotes.py @@ -27,6 +27,11 @@ class Note: trigger_conditions: List[TriggerCondition] pinned: bool = False name: str = "" + tags: List[str] = None + + def __post_init__(self): + if self.tags is None: + self.tags = [] @dataclass diff --git a/pyproject.toml b/pyproject.toml index 7b1206b..9444714 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ComfyUI-AutoNotes" -version = "0.0.1" +version = "0.0.2" description = "Automatically display context-aware notes based on workflow state, selected nodes, and node attributes in ComfyUI." authors = [ {name = "John Cantu", email = "jfcantu@gmail.com"} diff --git a/web/autonotes.js b/web/autonotes.js index 82a79ad..b77a7f8 100644 --- a/web/autonotes.js +++ b/web/autonotes.js @@ -243,10 +243,44 @@ class AutoNotesManager { modeContainer.appendChild(modeLabel); modeContainer.appendChild(modeSelect); + // Tag filter section + const tagFilterContainer = document.createElement('div'); + tagFilterContainer.style.marginBottom = '10px'; + + const tagFilterLabel = document.createElement('label'); + tagFilterLabel.textContent = 'Filter by Tags: '; + tagFilterLabel.style.cssText = ` + display: block; + margin-bottom: 5px; + `; + + this.tagFilterSelect = document.createElement('div'); + this.tagFilterSelect.style.cssText = ` + background: #333; + border: 1px solid #555; + padding: 5px; + border-radius: 4px; + min-height: 30px; + cursor: pointer; + position: relative; + `; + this.tagFilterSelect.textContent = 'Click to select tags...'; + + this.selectedTagFilters = new Set(); + + this.tagFilterSelect.addEventListener('click', (e) => { + e.stopPropagation(); + this.showTagFilterDropdown(); + }); + + tagFilterContainer.appendChild(tagFilterLabel); + tagFilterContainer.appendChild(this.tagFilterSelect); + // Buttons const buttonContainer = document.createElement('div'); buttonContainer.style.display = 'flex'; buttonContainer.style.gap = '10px'; + buttonContainer.style.flexWrap = 'wrap'; const addButton = document.createElement('button'); addButton.textContent = 'Add'; @@ -260,6 +294,19 @@ class AutoNotesManager { `; addButton.addEventListener('click', () => this.addNote()); + const addFromCurrentButton = document.createElement('button'); + addFromCurrentButton.textContent = 'Add From Current Node'; + addFromCurrentButton.style.cssText = ` + background: #00a000; + color: white; + border: none; + padding: 8px 16px; + cursor: pointer; + border-radius: 4px; + flex: 1; + `; + addFromCurrentButton.addEventListener('click', () => this.addNoteFromCurrentNode()); + const editButton = document.createElement('button'); editButton.textContent = 'Edit'; editButton.style.cssText = ` @@ -273,9 +320,11 @@ class AutoNotesManager { editButton.addEventListener('click', () => this.openEditDialog()); buttonContainer.appendChild(addButton); + buttonContainer.appendChild(addFromCurrentButton); buttonContainer.appendChild(editButton); controlPanel.appendChild(modeContainer); + controlPanel.appendChild(tagFilterContainer); controlPanel.appendChild(buttonContainer); // Notes display area @@ -419,9 +468,22 @@ class AutoNotesManager { renderNotes() { this.notesContainer.innerHTML = ''; - if (this.notes.length === 0) { + // Apply tag filtering + let filteredNotes = this.notes; + if (this.selectedTagFilters.size > 0) { + filteredNotes = this.notes.filter(note => { + const noteTags = note.tags || []; + // Note must have ALL selected tags (AND logic) + return Array.from(this.selectedTagFilters).every(filterTag => + noteTags.includes(filterTag) + ); + }); + } + + if (filteredNotes.length === 0) { const emptyMessage = document.createElement('div'); - emptyMessage.textContent = 'No notes to display'; + emptyMessage.textContent = this.selectedTagFilters.size > 0 ? + 'No notes match the selected tags' : 'No notes to display'; emptyMessage.style.cssText = ` text-align: center; color: #888; @@ -432,11 +494,165 @@ class AutoNotesManager { return; } - for (const note of this.notes) { + for (const note of filteredNotes) { this.renderNote(note); } } + showTagFilterDropdown() { + // Collect all unique tags from all notes + const allTags = new Set(); + for (const note of this.notes) { + if (note.tags) { + note.tags.forEach(tag => allTags.add(tag)); + } + } + + if (allTags.size === 0) { + alert('No tags found. Add tags to notes first.'); + return; + } + + // Remove existing dropdown + const existingDropdown = document.querySelector('.tag-filter-dropdown'); + if (existingDropdown) { + existingDropdown.remove(); + return; + } + + // Create dropdown + const dropdown = document.createElement('div'); + dropdown.className = 'tag-filter-dropdown'; + dropdown.style.cssText = ` + position: absolute; + top: 100%; + left: 0; + right: 0; + background: #2a2a2a; + border: 1px solid #555; + border-radius: 4px; + margin-top: 2px; + max-height: 200px; + overflow-y: auto; + z-index: 1000; + `; + + // Add "Clear All" option + const clearOption = document.createElement('div'); + clearOption.textContent = 'Clear All Filters'; + clearOption.style.cssText = ` + padding: 8px 10px; + cursor: pointer; + color: #f88; + border-bottom: 1px solid #555; + `; + clearOption.addEventListener('mouseenter', () => { + clearOption.style.background = '#333'; + }); + clearOption.addEventListener('mouseleave', () => { + clearOption.style.background = ''; + }); + clearOption.addEventListener('click', () => { + this.selectedTagFilters.clear(); + this.updateTagFilterDisplay(); + this.refreshNotes(); + dropdown.remove(); + }); + dropdown.appendChild(clearOption); + + // Add tag options + Array.from(allTags).sort().forEach(tag => { + const option = document.createElement('div'); + option.style.cssText = ` + padding: 8px 10px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + `; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = this.selectedTagFilters.has(tag); + + const label = document.createElement('span'); + label.textContent = tag; + + option.addEventListener('mouseenter', () => { + option.style.background = '#333'; + }); + option.addEventListener('mouseleave', () => { + option.style.background = ''; + }); + option.addEventListener('click', () => { + if (this.selectedTagFilters.has(tag)) { + this.selectedTagFilters.delete(tag); + checkbox.checked = false; + } else { + this.selectedTagFilters.add(tag); + checkbox.checked = true; + } + this.updateTagFilterDisplay(); + this.refreshNotes(); + }); + + option.appendChild(checkbox); + option.appendChild(label); + dropdown.appendChild(option); + }); + + this.tagFilterSelect.style.position = 'relative'; + this.tagFilterSelect.appendChild(dropdown); + + // Close dropdown when clicking outside + const closeDropdown = (e) => { + if (!this.tagFilterSelect.contains(e.target)) { + dropdown.remove(); + document.removeEventListener('click', closeDropdown); + } + }; + setTimeout(() => { + document.addEventListener('click', closeDropdown); + }, 0); + } + + updateTagFilterDisplay() { + if (this.selectedTagFilters.size === 0) { + this.tagFilterSelect.innerHTML = 'Click to select tags...'; + this.tagFilterSelect.style.color = ''; + } else { + this.tagFilterSelect.innerHTML = ''; + this.tagFilterSelect.style.color = '#fff'; + this.tagFilterSelect.style.cssText += ` + display: flex; + flex-wrap: wrap; + gap: 5px; + `; + + Array.from(this.selectedTagFilters).forEach(tag => { + const tagBadge = document.createElement('span'); + tagBadge.style.cssText = ` + background: #007acc; + padding: 2px 6px; + border-radius: 3px; + font-size: 11px; + display: inline-block; + `; + tagBadge.textContent = tag; + this.tagFilterSelect.appendChild(tagBadge); + }); + + // Re-add base styles + this.tagFilterSelect.style.background = '#333'; + this.tagFilterSelect.style.border = '1px solid #555'; + this.tagFilterSelect.style.padding = '5px'; + this.tagFilterSelect.style.borderRadius = '4px'; + this.tagFilterSelect.style.minHeight = '30px'; + this.tagFilterSelect.style.cursor = 'pointer'; + this.tagFilterSelect.style.position = 'relative'; + } + } + renderNote(note) { const noteElement = document.createElement('div'); noteElement.style.cssText = ` @@ -501,6 +717,119 @@ class AutoNotesManager { border-top: 1px solid #555; `; + // Tags section (at the top) + const tagsSection = document.createElement('div'); + tagsSection.style.cssText = ` + padding: 10px; + border-bottom: 1px solid #555; + background: #252525; + `; + + const tagsHeader = document.createElement('div'); + tagsHeader.style.cssText = ` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 5px; + `; + + const tagsLabel = document.createElement('span'); + tagsLabel.textContent = 'Tags:'; + tagsLabel.style.cssText = ` + font-size: 11px; + color: #aaa; + `; + + const addTagBtn = document.createElement('button'); + addTagBtn.textContent = '+ Tag'; + addTagBtn.style.cssText = ` + background: #007acc; + color: white; + border: none; + padding: 2px 6px; + cursor: pointer; + border-radius: 3px; + font-size: 10px; + `; + addTagBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + const tag = prompt('Enter tag name:'); + if (tag && tag.trim()) { + const trimmedTag = tag.trim(); + if (!note.tags) note.tags = []; + if (!note.tags.includes(trimmedTag)) { + note.tags.push(trimmedTag); + await this.updateNoteTags(note); + this.renderNotes(); + } + } + }); + + tagsHeader.appendChild(tagsLabel); + tagsHeader.appendChild(addTagBtn); + + const tagsContainer = document.createElement('div'); + tagsContainer.style.cssText = ` + display: flex; + flex-wrap: wrap; + gap: 5px; + min-height: 20px; + `; + + if (note.tags && note.tags.length > 0) { + note.tags.forEach(tag => { + const tagBadge = document.createElement('div'); + tagBadge.style.cssText = ` + background: #007acc; + color: white; + padding: 3px 6px; + border-radius: 3px; + font-size: 11px; + display: flex; + align-items: center; + gap: 4px; + `; + + const tagText = document.createElement('span'); + tagText.textContent = tag; + + const removeBtn = document.createElement('button'); + removeBtn.textContent = '×'; + removeBtn.style.cssText = ` + background: transparent; + border: none; + color: white; + cursor: pointer; + font-size: 14px; + padding: 0; + line-height: 1; + `; + removeBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + note.tags = note.tags.filter(t => t !== tag); + await this.updateNoteTags(note); + this.renderNotes(); + }); + + tagBadge.appendChild(tagText); + tagBadge.appendChild(removeBtn); + tagsContainer.appendChild(tagBadge); + }); + } else { + const noTags = document.createElement('span'); + noTags.textContent = 'No tags'; + noTags.style.cssText = ` + font-size: 11px; + color: #666; + font-style: italic; + `; + tagsContainer.appendChild(noTags); + } + + tagsSection.appendChild(tagsHeader); + tagsSection.appendChild(tagsContainer); + contentContainer.appendChild(tagsSection); + // Mode toggle (for markdown notes) let isEditMode = false; let modeToggle = null; @@ -546,10 +875,15 @@ class AutoNotesManager { modeToggle = { viewBtn, editBtn }; } - // View content (rendered markdown or plain text) + // View content (rendered markdown or plain text) - resizable const viewContent = document.createElement('div'); viewContent.style.cssText = ` padding: 10px; + min-height: 50px; + max-height: none; + overflow-y: auto; + position: relative; + resize: vertical; `; if (note.format_style === 'markdown') { @@ -590,8 +924,28 @@ class AutoNotesManager { display: none; `; - saveBtn.addEventListener('click', async () => { + // Auto-save function + const autoSave = async () => { await this.updateNoteContent(note.uuid, editContent.value); + note.content = editContent.value; + // Update view content + if (note.format_style === 'markdown') { + viewContent.innerHTML = this.renderMarkdown(note.content); + } else { + viewContent.textContent = note.content; + } + }; + + saveBtn.addEventListener('click', async () => { + await autoSave(); + }); + + // Auto-save when clicking off the textarea + editContent.addEventListener('blur', async () => { + // Only auto-save if content has changed + if (editContent.value !== note.content) { + await autoSave(); + } }); contentContainer.appendChild(viewContent); @@ -600,7 +954,16 @@ class AutoNotesManager { // Toggle between view and edit mode if (modeToggle) { - const switchToView = () => { + const switchToView = async () => { + // Auto-save before switching to view + if (isEditMode && editContent.value !== note.content) { + await autoSave(); + } + // Copy the current height from editContent to viewContent + const currentHeight = editContent.offsetHeight; + if (currentHeight > 0) { + viewContent.style.height = currentHeight + 'px'; + } isEditMode = false; viewContent.style.display = 'block'; editContent.style.display = 'none'; @@ -611,6 +974,11 @@ class AutoNotesManager { const switchToEdit = () => { isEditMode = true; + // Copy the current height from viewContent to editContent + const currentHeight = viewContent.offsetHeight; + if (currentHeight > 0) { + editContent.style.height = currentHeight + 'px'; + } viewContent.style.display = 'none'; editContent.style.display = 'block'; saveBtn.style.display = 'block'; @@ -618,9 +986,9 @@ class AutoNotesManager { modeToggle.editBtn.style.background = '#007acc'; }; - modeToggle.viewBtn.addEventListener('click', (e) => { + modeToggle.viewBtn.addEventListener('click', async (e) => { e.stopPropagation(); - switchToView(); + await switchToView(); }); modeToggle.editBtn.addEventListener('click', (e) => { @@ -631,6 +999,11 @@ class AutoNotesManager { // For plain text, click to edit directly viewContent.addEventListener('click', (e) => { e.stopPropagation(); + // Copy the current height from viewContent to editContent + const currentHeight = viewContent.offsetHeight; + if (currentHeight > 0) { + editContent.style.height = currentHeight + 'px'; + } viewContent.style.display = 'none'; editContent.style.display = 'block'; saveBtn.style.display = 'block'; @@ -701,6 +1074,55 @@ class AutoNotesManager { } } + async addNoteFromCurrentNode() { + // Check if a node is selected + if (!this.selectedNodeType) { + alert('Please select a node first'); + return; + } + + // Load folders first if not already loaded + await this.loadFolders(); + + // Show dialog to configure trigger and note details + const result = await this.showAddNoteFromCurrentNodeDialog(); + if (!result) return; + + try { + // Create the note + const response = await api.fetchApi('/autonotes/notes', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: result.name, + folder_uuid: result.folder_uuid + }), + }); + + const noteResult = await response.json(); + if (noteResult.uuid) { + // Update the note with the trigger condition + await api.fetchApi(`/autonotes/notes/${noteResult.uuid}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + trigger_conditions: [result.triggerCondition] + }), + }); + + // Just refresh notes - don't open the edit dialog + await this.refreshNotes(); + } + } catch (error) { + console.error('Failed to create note:', error); + alert('Failed to create note'); + } + } + async showAddNoteDialog() { return new Promise((resolve) => { // Create overlay @@ -955,6 +1377,506 @@ class AutoNotesManager { } } + async showAddNoteFromCurrentNodeDialog() { + return new Promise((resolve) => { + // Create overlay + const overlay = document.createElement('div'); + overlay.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + z-index: 3000; + display: flex; + justify-content: center; + align-items: center; + `; + + // Create dialog + const dialog = document.createElement('div'); + dialog.style.cssText = ` + background: #2a2a2a; + border: 1px solid #555; + border-radius: 8px; + padding: 20px; + width: 500px; + max-height: 80vh; + overflow-y: auto; + color: #fff; + font-family: Arial, sans-serif; + `; + + // Title + const title = document.createElement('h2'); + title.textContent = 'Create Note from Current Node'; + title.style.cssText = ` + margin: 0 0 20px 0; + font-size: 1.3em; + `; + + // Current node info + const nodeInfo = document.createElement('div'); + nodeInfo.style.cssText = ` + background: #1a1a1a; + padding: 10px; + border-radius: 4px; + margin-bottom: 20px; + border: 1px solid #555; + `; + const nodeInfoText = document.createElement('div'); + nodeInfoText.textContent = `Selected Node: ${this.nodeTypes[this.selectedNodeType]?.display_name || this.selectedNodeType}`; + nodeInfoText.style.fontWeight = 'bold'; + nodeInfo.appendChild(nodeInfoText); + + // Name input + const nameLabel = document.createElement('label'); + nameLabel.textContent = 'Note Name:'; + nameLabel.style.cssText = ` + display: block; + margin-bottom: 5px; + `; + + const nameInput = document.createElement('input'); + nameInput.type = 'text'; + nameInput.value = `Note for ${this.nodeTypes[this.selectedNodeType]?.display_name || this.selectedNodeType}`; + nameInput.style.cssText = ` + width: 100%; + padding: 8px; + background: #1a1a1a; + color: #fff; + border: 1px solid #555; + border-radius: 4px; + box-sizing: border-box; + margin-bottom: 20px; + font-size: 14px; + `; + + // Trigger type selector + const triggerTypeLabel = document.createElement('label'); + triggerTypeLabel.textContent = 'Trigger Type:'; + triggerTypeLabel.style.cssText = ` + display: block; + margin-bottom: 5px; + `; + + const triggerTypeSelect = document.createElement('select'); + triggerTypeSelect.style.cssText = ` + width: 100%; + padding: 8px; + background: #1a1a1a; + color: #fff; + border: 1px solid #555; + border-radius: 4px; + box-sizing: border-box; + margin-bottom: 20px; + font-size: 14px; + `; + triggerTypeSelect.innerHTML = ` + + + + + `; + + // Dynamic fields container + const dynamicFieldsContainer = document.createElement('div'); + dynamicFieldsContainer.style.marginBottom = '20px'; + + // Trigger condition object + const triggerCondition = { + type: 'node_selected', + node_types: [this.selectedNodeType], + node_type: this.selectedNodeType, + attribute_name: null, + attribute_values: [], + workflow_names: [] + }; + + // Function to update dynamic fields based on trigger type + const updateDynamicFields = () => { + dynamicFieldsContainer.innerHTML = ''; + triggerCondition.type = triggerTypeSelect.value; + + if (triggerCondition.type === 'node_selected') { + // Already set with current node type + triggerCondition.node_types = [this.selectedNodeType]; + const info = document.createElement('div'); + info.style.cssText = ` + background: #1a1a1a; + padding: 10px; + border-radius: 4px; + border: 1px solid #555; + color: #aaa; + `; + info.textContent = `This note will appear when "${this.nodeTypes[this.selectedNodeType]?.display_name || this.selectedNodeType}" is selected.`; + dynamicFieldsContainer.appendChild(info); + + } else if (triggerCondition.type === 'node_attribute') { + // Attribute selector + const attrLabel = document.createElement('label'); + attrLabel.textContent = 'Attribute:'; + attrLabel.style.cssText = ` + display: block; + margin-bottom: 5px; + `; + + const attrSelect = document.createElement('select'); + attrSelect.style.cssText = ` + width: 100%; + padding: 8px; + background: #1a1a1a; + color: #fff; + border: 1px solid #555; + border-radius: 4px; + margin-bottom: 10px; + font-size: 14px; + `; + + // Populate with node attributes + const emptyOption = document.createElement('option'); + emptyOption.value = ''; + emptyOption.textContent = '-- Select Attribute --'; + attrSelect.appendChild(emptyOption); + + if (this.nodeTypes[this.selectedNodeType]) { + const nodeInfo = this.nodeTypes[this.selectedNodeType]; + const inputs = nodeInfo.input?.required || {}; + const optionalInputs = nodeInfo.input?.optional || {}; + const allInputs = { ...inputs, ...optionalInputs }; + + Object.keys(allInputs).forEach(attrName => { + const option = document.createElement('option'); + option.value = attrName; + option.textContent = attrName; + attrSelect.appendChild(option); + }); + } + + // Value input + const valueLabel = document.createElement('label'); + valueLabel.textContent = 'Attribute Value (to match):'; + valueLabel.style.cssText = ` + display: block; + margin-bottom: 5px; + `; + + const valueInput = document.createElement('input'); + valueInput.type = 'text'; + valueInput.placeholder = 'Enter value...'; + valueInput.style.cssText = ` + width: 100%; + padding: 8px; + background: #1a1a1a; + color: #fff; + border: 1px solid #555; + border-radius: 4px; + font-size: 14px; + `; + + // Update value input when attribute changes + attrSelect.addEventListener('change', () => { + triggerCondition.attribute_name = attrSelect.value; + // Pre-fill with current value if available + if (this.selectedNodeAttributes[attrSelect.value] !== undefined) { + valueInput.value = String(this.selectedNodeAttributes[attrSelect.value]); + triggerCondition.attribute_values = [valueInput.value]; + } else { + valueInput.value = ''; + triggerCondition.attribute_values = []; + } + }); + + valueInput.addEventListener('input', () => { + triggerCondition.attribute_values = valueInput.value ? [valueInput.value] : []; + }); + + dynamicFieldsContainer.appendChild(attrLabel); + dynamicFieldsContainer.appendChild(attrSelect); + dynamicFieldsContainer.appendChild(valueLabel); + dynamicFieldsContainer.appendChild(valueInput); + + } else if (triggerCondition.type === 'node_in_workflow') { + // Already set with current node type + triggerCondition.node_types = [this.selectedNodeType]; + const info = document.createElement('div'); + info.style.cssText = ` + background: #1a1a1a; + padding: 10px; + border-radius: 4px; + border: 1px solid #555; + color: #aaa; + `; + info.textContent = `This note will appear when "${this.nodeTypes[this.selectedNodeType]?.display_name || this.selectedNodeType}" exists anywhere in the workflow.`; + dynamicFieldsContainer.appendChild(info); + + } else if (triggerCondition.type === 'attribute_in_workflow') { + // Attribute selector + const attrLabel = document.createElement('label'); + attrLabel.textContent = 'Attribute:'; + attrLabel.style.cssText = ` + display: block; + margin-bottom: 5px; + `; + + const attrSelect = document.createElement('select'); + attrSelect.style.cssText = ` + width: 100%; + padding: 8px; + background: #1a1a1a; + color: #fff; + border: 1px solid #555; + border-radius: 4px; + margin-bottom: 10px; + font-size: 14px; + `; + + // Populate with node attributes + const emptyOption = document.createElement('option'); + emptyOption.value = ''; + emptyOption.textContent = '-- Select Attribute --'; + attrSelect.appendChild(emptyOption); + + if (this.nodeTypes[this.selectedNodeType]) { + const nodeInfo = this.nodeTypes[this.selectedNodeType]; + const inputs = nodeInfo.input?.required || {}; + const optionalInputs = nodeInfo.input?.optional || {}; + const allInputs = { ...inputs, ...optionalInputs }; + + Object.keys(allInputs).forEach(attrName => { + const option = document.createElement('option'); + option.value = attrName; + option.textContent = attrName; + attrSelect.appendChild(option); + }); + } + + // Value input (optional) + const valueLabel = document.createElement('label'); + valueLabel.textContent = 'Attribute Value (optional, leave blank for any):'; + valueLabel.style.cssText = ` + display: block; + margin-bottom: 5px; + `; + + const valueInput = document.createElement('input'); + valueInput.type = 'text'; + valueInput.placeholder = 'Enter value or leave blank...'; + valueInput.style.cssText = ` + width: 100%; + padding: 8px; + background: #1a1a1a; + color: #fff; + border: 1px solid #555; + border-radius: 4px; + font-size: 14px; + `; + + // Update value input when attribute changes + attrSelect.addEventListener('change', () => { + triggerCondition.attribute_name = attrSelect.value; + // Pre-fill with current value if available + if (this.selectedNodeAttributes[attrSelect.value] !== undefined) { + valueInput.value = String(this.selectedNodeAttributes[attrSelect.value]); + triggerCondition.attribute_values = [valueInput.value]; + } else { + valueInput.value = ''; + triggerCondition.attribute_values = []; + } + }); + + valueInput.addEventListener('input', () => { + triggerCondition.attribute_values = valueInput.value ? [valueInput.value] : []; + }); + + dynamicFieldsContainer.appendChild(attrLabel); + dynamicFieldsContainer.appendChild(attrSelect); + dynamicFieldsContainer.appendChild(valueLabel); + dynamicFieldsContainer.appendChild(valueInput); + } + }; + + triggerTypeSelect.addEventListener('change', updateDynamicFields); + updateDynamicFields(); + + // Folder selection + const folderLabel = document.createElement('label'); + folderLabel.textContent = 'Folder:'; + folderLabel.style.cssText = ` + display: block; + margin-bottom: 5px; + `; + + const folderTreeContainer = document.createElement('div'); + folderTreeContainer.style.cssText = ` + max-height: 150px; + overflow-y: auto; + background: #1a1a1a; + border: 1px solid #555; + border-radius: 4px; + padding: 10px; + margin-bottom: 20px; + `; + + let selectedFolderUuid = null; + + // Build folder tree + const buildFolderTree = (parentUuid, level) => { + const children = this.folders.filter(f => f.parent_uuid === parentUuid); + children.forEach(folder => { + const folderOption = document.createElement('div'); + folderOption.style.cssText = ` + padding: 5px; + padding-left: ${level * 15}px; + cursor: pointer; + border-radius: 3px; + `; + folderOption.textContent = `📁 ${folder.name}`; + + folderOption.addEventListener('mouseenter', () => { + folderOption.style.background = '#007acc'; + }); + folderOption.addEventListener('mouseleave', () => { + if (selectedFolderUuid !== folder.uuid) { + folderOption.style.background = ''; + } + }); + folderOption.addEventListener('click', () => { + // Deselect previous + folderTreeContainer.querySelectorAll('div').forEach(el => { + el.style.background = ''; + }); + selectedFolderUuid = folder.uuid; + folderOption.style.background = '#007acc'; + }); + + folderTreeContainer.appendChild(folderOption); + buildFolderTree(folder.uuid, level + 1); + }); + }; + + // Add root option + const rootOption = document.createElement('div'); + rootOption.style.cssText = ` + padding: 5px; + cursor: pointer; + border-radius: 3px; + background: #007acc; + `; + rootOption.textContent = '[Root]'; + rootOption.addEventListener('mouseenter', () => { + rootOption.style.background = '#007acc'; + }); + rootOption.addEventListener('mouseleave', () => { + if (selectedFolderUuid !== null) { + rootOption.style.background = ''; + } + }); + rootOption.addEventListener('click', () => { + folderTreeContainer.querySelectorAll('div').forEach(el => { + el.style.background = ''; + }); + selectedFolderUuid = null; + rootOption.style.background = '#007acc'; + }); + folderTreeContainer.appendChild(rootOption); + buildFolderTree(null, 1); + + // Buttons + const buttonContainer = document.createElement('div'); + buttonContainer.style.cssText = ` + display: flex; + justify-content: flex-end; + gap: 10px; + `; + + const cancelBtn = document.createElement('button'); + cancelBtn.textContent = 'Cancel'; + cancelBtn.style.cssText = ` + background: #666; + color: white; + border: none; + padding: 8px 16px; + cursor: pointer; + border-radius: 4px; + `; + cancelBtn.addEventListener('click', () => { + document.body.removeChild(overlay); + resolve(null); + }); + + const createBtn = document.createElement('button'); + createBtn.textContent = 'Create'; + createBtn.style.cssText = ` + background: #007acc; + color: white; + border: none; + padding: 8px 16px; + cursor: pointer; + border-radius: 4px; + `; + createBtn.addEventListener('click', () => { + const name = nameInput.value.trim(); + if (!name) { + alert('Please enter a note name'); + return; + } + + // Validate trigger condition + if (triggerCondition.type === 'node_attribute' || triggerCondition.type === 'attribute_in_workflow') { + if (!triggerCondition.attribute_name) { + alert('Please select an attribute'); + return; + } + } + + document.body.removeChild(overlay); + resolve({ + name, + folder_uuid: selectedFolderUuid, + triggerCondition + }); + }); + + // Handle Enter key + nameInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + createBtn.click(); + } + }); + + // Focus name input + setTimeout(() => nameInput.focus(), 0); + + // Assemble dialog + buttonContainer.appendChild(cancelBtn); + buttonContainer.appendChild(createBtn); + + dialog.appendChild(title); + dialog.appendChild(nodeInfo); + dialog.appendChild(nameLabel); + dialog.appendChild(nameInput); + dialog.appendChild(triggerTypeLabel); + dialog.appendChild(triggerTypeSelect); + dialog.appendChild(dynamicFieldsContainer); + dialog.appendChild(folderLabel); + dialog.appendChild(folderTreeContainer); + dialog.appendChild(buttonContainer); + + overlay.appendChild(dialog); + document.body.appendChild(overlay); + + // Close on overlay click + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { + document.body.removeChild(overlay); + resolve(null); + } + }); + }); + } + async loadFolders() { try { const response = await api.fetchApi('/autonotes/folders'); @@ -1159,9 +2081,13 @@ class AutoNotesManager { e.preventDefault(); treeContainer.style.background = ''; - const noteUuid = e.dataTransfer.getData('text/plain'); + const noteUuid = e.dataTransfer.getData('application/x-note-uuid'); + const folderUuid = e.dataTransfer.getData('application/x-folder-uuid'); + if (noteUuid) { await this.moveNoteToFolder(noteUuid, null, treeContainer, selectedNoteUuid); + } else if (folderUuid) { + await this.moveFolderToFolder(folderUuid, null, treeContainer, selectedNoteUuid); } } }); @@ -1180,6 +2106,39 @@ class AutoNotesManager { this.editDialog.appendChild(treeContainer); this.editDialog.appendChild(editPanel); + // Auto-save when clicking anywhere in the dialog (except textarea) + this.editDialog.addEventListener('click', async (e) => { + if (e.target !== this.contentTextarea && this.currentEditingNote && this.textareaChanged) { + await this.saveCurrentNote(); + this.textareaChanged = false; + } + }); + + // Auto-save when clicking on the overlay (outside dialog) + overlay.addEventListener('click', async (e) => { + if (e.target === overlay && this.currentEditingNote && this.textareaChanged) { + await this.saveCurrentNote(); + this.textareaChanged = false; + } + }); + + // Global document-level click handler for auto-save (catches clicks outside dialog) + this.globalAutoSaveHandler = async (e) => { + // Only auto-save if dialog is open and there are changes + if (this.editDialog && this.currentEditingNote && this.textareaChanged) { + // Check if click is outside the dialog and not on the textarea + if (!this.editDialog.contains(e.target) && e.target !== this.contentTextarea) { + await this.saveCurrentNote(); + this.textareaChanged = false; + } + } + }; + + // Add the handler with a slight delay to avoid immediate triggering + setTimeout(() => { + document.addEventListener('click', this.globalAutoSaveHandler, true); + }, 100); + overlay.appendChild(this.editDialog); document.body.appendChild(overlay); } @@ -1189,13 +2148,14 @@ class AutoNotesManager { const existingItems = container.querySelectorAll('.tree-item, .folder-item'); existingItems.forEach(item => item.remove()); - // Render folders first - for (const folder of this.folders) { + // Render only root-level folders (parent_uuid === null) + const rootFolders = this.folders.filter(f => !f.parent_uuid); + for (const folder of rootFolders) { const folderItem = this.createFolderItem(folder, container, selectedNoteUuid); container.appendChild(folderItem); } - // Render notes (only top-level notes without folders for now) + // Render notes (only top-level notes without folders) for (const note of this.notes) { if (!note.folder_uuid) { const noteItem = this.createNoteItem(note, selectedNoteUuid, container); @@ -1212,6 +2172,7 @@ class AutoNotesManager { `; const folderHeader = document.createElement('div'); + folderHeader.draggable = true; folderHeader.style.cssText = ` display: flex; align-items: center; @@ -1230,26 +2191,54 @@ class AutoNotesManager { font-size: 10px; `; + // Make folder draggable + folderHeader.addEventListener('dragstart', (e) => { + e.stopPropagation(); + e.dataTransfer.setData('application/x-folder-uuid', folder.uuid); + e.dataTransfer.effectAllowed = 'move'; + folderHeader.style.opacity = '0.5'; + }); + + folderHeader.addEventListener('dragend', (e) => { + folderHeader.style.opacity = '1'; + }); + // Make folder a drop target folderHeader.addEventListener('dragover', (e) => { e.preventDefault(); + e.stopPropagation(); folderHeader.style.background = '#007acc'; }); folderHeader.addEventListener('dragleave', (e) => { + e.stopPropagation(); folderHeader.style.background = '#333'; }); folderHeader.addEventListener('drop', async (e) => { e.preventDefault(); + e.stopPropagation(); folderHeader.style.background = '#333'; - const noteUuid = e.dataTransfer.getData('text/plain'); + // Check if it's a note or folder being dropped + const noteUuid = e.dataTransfer.getData('application/x-note-uuid'); + const folderUuid = e.dataTransfer.getData('application/x-folder-uuid'); + if (noteUuid) { await this.moveNoteToFolder(noteUuid, folder.uuid, treeContainer, selectedNoteUuid); + } else if (folderUuid && folderUuid !== folder.uuid) { + // Prevent dropping a folder into itself + await this.moveFolderToFolder(folderUuid, folder.uuid, treeContainer, selectedNoteUuid); } }); + // Right-click context menu + folderHeader.addEventListener('contextmenu', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.showFolderContextMenu(e, folder, treeContainer, selectedNoteUuid); + }); + const folderName = document.createElement('span'); folderName.textContent = `📁 ${folder.name}`; folderName.style.flex = '1'; @@ -1291,23 +2280,30 @@ class AutoNotesManager { folderHeader.appendChild(renameBtn); folderHeader.appendChild(deleteBtn); - // Notes container (children) - const notesContainer = document.createElement('div'); - notesContainer.style.cssText = ` + // Children container (child folders and notes) + const childrenContainer = document.createElement('div'); + childrenContainer.style.cssText = ` padding-left: 20px; display: none; `; + // Find child folders + const childFolders = this.folders.filter(f => f.parent_uuid === folder.uuid); + for (const childFolder of childFolders) { + const childFolderItem = this.createFolderItem(childFolder, treeContainer, selectedNoteUuid); + childrenContainer.appendChild(childFolderItem); + } + // Find notes in this folder const folderNotes = this.notes.filter(note => note.folder_uuid === folder.uuid); for (const note of folderNotes) { const noteItem = this.createNoteItem(note, selectedNoteUuid, treeContainer); - notesContainer.appendChild(noteItem); + childrenContainer.appendChild(noteItem); } // Toggle expand/collapse const isExpanded = this.expandedFolders.has(folder.uuid); - notesContainer.style.display = isExpanded ? 'block' : 'none'; + childrenContainer.style.display = isExpanded ? 'block' : 'none'; expandIcon.style.transform = isExpanded ? 'rotate(90deg)' : 'rotate(0deg)'; folderHeader.addEventListener('click', (e) => { @@ -1318,17 +2314,17 @@ class AutoNotesManager { if (this.expandedFolders.has(folder.uuid)) { this.expandedFolders.delete(folder.uuid); - notesContainer.style.display = 'none'; + childrenContainer.style.display = 'none'; expandIcon.style.transform = 'rotate(0deg)'; } else { this.expandedFolders.add(folder.uuid); - notesContainer.style.display = 'block'; + childrenContainer.style.display = 'block'; expandIcon.style.transform = 'rotate(90deg)'; } }); folderItem.appendChild(folderHeader); - folderItem.appendChild(notesContainer); + folderItem.appendChild(childrenContainer); return folderItem; } @@ -1348,7 +2344,8 @@ class AutoNotesManager { // Drag start noteItem.addEventListener('dragstart', (e) => { - e.dataTransfer.setData('text/plain', note.uuid); + e.dataTransfer.setData('application/x-note-uuid', note.uuid); + e.dataTransfer.effectAllowed = 'move'; noteItem.style.opacity = '0.5'; }); @@ -1358,7 +2355,13 @@ class AutoNotesManager { }); // Left click to select - noteItem.addEventListener('click', () => { + noteItem.addEventListener('click', async () => { + // Auto-save current note before switching + if (this.currentEditingNote && this.textareaChanged) { + await this.saveCurrentNote(); + this.textareaChanged = false; + } + // Remove previous selection treeContainer.querySelectorAll('.tree-item').forEach(item => { item.style.background = ''; @@ -1404,6 +2407,31 @@ class AutoNotesManager { font-family: monospace; `; + // Track if content has changed + this.textareaChanged = false; + + // Auto-save on input (debounced) + let saveTimeout; + this.contentTextarea.addEventListener('input', () => { + this.textareaChanged = true; + clearTimeout(saveTimeout); + saveTimeout = setTimeout(async () => { + if (this.currentEditingNote && this.textareaChanged) { + await this.saveCurrentNote(); + this.textareaChanged = false; + } + }, 1000); // Auto-save after 1 second of inactivity + }); + + // Auto-save on blur (when clicking outside the textarea) + this.contentTextarea.addEventListener('blur', async () => { + if (this.currentEditingNote && this.textareaChanged) { + clearTimeout(saveTimeout); + await this.saveCurrentNote(); + this.textareaChanged = false; + } + }); + contentContainer.appendChild(contentLabel); contentContainer.appendChild(this.contentTextarea); @@ -1453,6 +2481,66 @@ class AutoNotesManager { formatContainer.appendChild(formatDiv); formatContainer.appendChild(pinnedDiv); + // Tags section + const tagsContainer = document.createElement('div'); + tagsContainer.style.cssText = ` + margin-bottom: 10px; + border: 1px solid #555; + border-radius: 4px; + padding: 10px; + `; + + const tagsHeader = document.createElement('div'); + tagsHeader.style.cssText = ` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + `; + + const tagsTitle = document.createElement('h4'); + tagsTitle.textContent = 'Tags'; + tagsTitle.style.margin = '0'; + + const addTagButton = document.createElement('button'); + addTagButton.textContent = '+ Add Tag'; + addTagButton.style.cssText = ` + background: #007acc; + color: white; + border: none; + padding: 5px 10px; + cursor: pointer; + border-radius: 3px; + font-size: 11px; + `; + + tagsHeader.appendChild(tagsTitle); + tagsHeader.appendChild(addTagButton); + + this.tagsListContainer = document.createElement('div'); + this.tagsListContainer.style.cssText = ` + display: flex; + flex-wrap: wrap; + gap: 5px; + `; + + this.currentTags = []; + + addTagButton.addEventListener('click', () => { + const tag = prompt('Enter tag name:'); + if (tag && tag.trim()) { + const trimmedTag = tag.trim(); + if (!this.currentTags.includes(trimmedTag)) { + this.currentTags.push(trimmedTag); + this.renderTags(); + this.textareaChanged = true; // Mark as changed for auto-save + } + } + }); + + tagsContainer.appendChild(tagsHeader); + tagsContainer.appendChild(this.tagsListContainer); + // Trigger conditions section const triggerContainer = document.createElement('div'); triggerContainer.style.cssText = ` @@ -1561,6 +2649,7 @@ class AutoNotesManager { panel.appendChild(contentContainer); panel.appendChild(formatContainer); + panel.appendChild(tagsContainer); panel.appendChild(triggerContainer); panel.appendChild(buttonContainer); @@ -1579,11 +2668,57 @@ class AutoNotesManager { this.formatSelect.value = note.format_style || 'plaintext'; this.pinnedCheckbox.checked = note.pinned || false; + // Load tags + this.currentTags = [...(note.tags || [])]; + this.renderTags(); + // Load trigger conditions this.currentTriggerConditions = [...(note.trigger_conditions || [])]; this.renderTriggerConditions(); } + renderTags() { + this.tagsListContainer.innerHTML = ''; + + for (const tag of this.currentTags) { + const tagElement = document.createElement('div'); + tagElement.style.cssText = ` + background: #007acc; + color: white; + padding: 4px 8px; + border-radius: 3px; + font-size: 12px; + display: flex; + align-items: center; + gap: 5px; + `; + + const tagText = document.createElement('span'); + tagText.textContent = tag; + + const removeBtn = document.createElement('button'); + removeBtn.textContent = '×'; + removeBtn.style.cssText = ` + background: transparent; + border: none; + color: white; + cursor: pointer; + font-size: 16px; + padding: 0; + line-height: 1; + `; + removeBtn.addEventListener('click', () => { + this.currentTags = this.currentTags.filter(t => t !== tag); + this.renderTags(); + this.textareaChanged = true; // Mark as changed for auto-save + }); + + tagElement.appendChild(tagText); + tagElement.appendChild(removeBtn); + this.tagsListContainer.appendChild(tagElement); + } + } + addTriggerCondition() { const condition = { type: 'node_selected', @@ -2658,6 +3793,7 @@ class AutoNotesManager { content: this.contentTextarea.value, format_style: this.formatSelect.value, pinned: this.pinnedCheckbox.checked, + tags: this.currentTags || [], trigger_conditions: cleanedTriggerConditions }; @@ -2688,8 +3824,18 @@ class AutoNotesManager { } async closeEditDialog(save = false) { - if (save && this.currentEditingNote) { + // Always auto-save if there are unsaved changes + if (this.currentEditingNote && this.textareaChanged) { await this.saveCurrentNote(); + this.textareaChanged = false; + } else if (save && this.currentEditingNote) { + await this.saveCurrentNote(); + } + + // Remove global auto-save handler + if (this.globalAutoSaveHandler) { + document.removeEventListener('click', this.globalAutoSaveHandler, true); + this.globalAutoSaveHandler = null; } if (this.editDialog) { @@ -2824,6 +3970,169 @@ class AutoNotesManager { } } + async moveFolderToFolder(folderUuid, parentFolderUuid, treeContainer, selectedNoteUuid) { + try { + // Prevent circular references + if (folderUuid === parentFolderUuid) { + alert('Cannot move a folder into itself'); + return; + } + + // Check if parent is a descendant of the folder being moved + const isDescendant = (checkFolderId, ancestorId) => { + if (!checkFolderId) return false; + if (checkFolderId === ancestorId) return true; + const folder = this.folders.find(f => f.uuid === checkFolderId); + if (!folder) return false; + return isDescendant(folder.parent_uuid, ancestorId); + }; + + if (isDescendant(parentFolderUuid, folderUuid)) { + alert('Cannot move a folder into its own descendant'); + return; + } + + const response = await api.fetchApi(`/autonotes/folders/${folderUuid}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ parent_uuid: parentFolderUuid }), + }); + + const result = await response.json(); + if (result.success) { + await this.loadFolders(); + this.renderNoteTree(treeContainer, selectedNoteUuid); + } else { + alert('Failed to move folder'); + } + } catch (error) { + console.error('Failed to move folder:', error); + alert('Failed to move folder'); + } + } + + showFolderContextMenu(event, folder, treeContainer, selectedNoteUuid) { + // Remove any existing context menu + const existingMenu = document.querySelector('.folder-context-menu'); + if (existingMenu) { + existingMenu.remove(); + } + + const menu = document.createElement('div'); + menu.className = 'folder-context-menu'; + menu.style.cssText = ` + position: fixed; + left: ${event.clientX}px; + top: ${event.clientY}px; + background: #2a2a2a; + border: 1px solid #555; + border-radius: 4px; + padding: 5px 0; + z-index: 10000; + min-width: 150px; + box-shadow: 0 4px 8px rgba(0,0,0,0.3); + `; + + const createChildOption = document.createElement('div'); + createChildOption.textContent = 'New Child Folder'; + createChildOption.style.cssText = ` + padding: 8px 15px; + cursor: pointer; + color: #fff; + `; + createChildOption.addEventListener('mouseenter', () => { + createChildOption.style.background = '#007acc'; + }); + createChildOption.addEventListener('mouseleave', () => { + createChildOption.style.background = ''; + }); + createChildOption.addEventListener('click', async () => { + menu.remove(); + const folderName = prompt('Enter folder name:'); + if (folderName) { + await this.createFolderWithParent(folderName, folder.uuid, treeContainer, selectedNoteUuid); + } + }); + + const renameOption = document.createElement('div'); + renameOption.textContent = 'Rename'; + renameOption.style.cssText = ` + padding: 8px 15px; + cursor: pointer; + color: #fff; + `; + renameOption.addEventListener('mouseenter', () => { + renameOption.style.background = '#007acc'; + }); + renameOption.addEventListener('mouseleave', () => { + renameOption.style.background = ''; + }); + renameOption.addEventListener('click', async () => { + menu.remove(); + this.renameFolder(folder, treeContainer, selectedNoteUuid); + }); + + const deleteOption = document.createElement('div'); + deleteOption.textContent = 'Delete'; + deleteOption.style.cssText = ` + padding: 8px 15px; + cursor: pointer; + color: #f88; + `; + deleteOption.addEventListener('mouseenter', () => { + deleteOption.style.background = '#007acc'; + }); + deleteOption.addEventListener('mouseleave', () => { + deleteOption.style.background = ''; + }); + deleteOption.addEventListener('click', async () => { + menu.remove(); + this.deleteFolder(folder, treeContainer, selectedNoteUuid); + }); + + menu.appendChild(createChildOption); + menu.appendChild(renameOption); + menu.appendChild(deleteOption); + + document.body.appendChild(menu); + + // Close menu when clicking outside + const closeMenu = (e) => { + if (!menu.contains(e.target)) { + menu.remove(); + document.removeEventListener('click', closeMenu); + } + }; + setTimeout(() => { + document.addEventListener('click', closeMenu); + }, 0); + } + + async createFolderWithParent(name, parentUuid, treeContainer, selectedNoteUuid) { + try { + const response = await api.fetchApi('/autonotes/folders', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name, parent_uuid: parentUuid }), + }); + + const result = await response.json(); + if (result.uuid) { + await this.loadFolders(); + // Expand the parent folder + this.expandedFolders.add(parentUuid); + this.renderNoteTree(treeContainer, selectedNoteUuid); + } + } catch (error) { + console.error('Failed to create folder:', error); + alert('Failed to create folder'); + } + } + showNoteContextMenu(event, note, treeContainer, selectedNoteUuid) { // Remove any existing context menu const existingMenu = document.querySelector('.note-context-menu'); @@ -2972,6 +4281,34 @@ class AutoNotesManager { alert('Failed to save note'); } } + + async updateNoteTags(note) { + try { + const response = await api.fetchApi(`/autonotes/notes/${note.uuid}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + content: note.content, + format_style: note.format_style, + pinned: note.pinned, + tags: note.tags || [], + trigger_conditions: note.trigger_conditions || [] + }), + }); + + const result = await response.json(); + if (result.success) { + await this.refreshNotes(); + } else { + alert('Failed to update tags'); + } + } catch (error) { + console.error('Failed to update tags:', error); + alert('Failed to update tags'); + } + } } // Initialize the AutoNotes manager when the extension loads