// Snippet management and localStorage functionality // Generate unique ID using Date.now() + random numbers function generateSnippetId() { return Date.now() + Math.random() * 1000; } // Generate auto-populated name with current datetime function generateSnippetName() { const now = new Date(); const pad = (n) => String(n).padStart(2, '0'); return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`; } // Create a new snippet using Phase 0 schema function createSnippet(spec, name = null) { const now = new Date().toISOString(); return { id: generateSnippetId(), name: name || generateSnippetName(), created: now, modified: now, spec: spec, draftSpec: spec, // Initially same as spec comment: "", tags: [], datasetRefs: [], meta: {} }; } // LocalStorage wrapper with error handling const SnippetStorage = { STORAGE_KEY: 'astrolabe-snippets', // Save all snippets to localStorage saveSnippets(snippets) { try { localStorage.setItem(this.STORAGE_KEY, JSON.stringify(snippets)); return true; } catch (error) { console.error('Failed to save snippets to localStorage:', error); // TODO: Handle quota exceeded, show user error return false; } }, // Load all snippets from localStorage loadSnippets() { try { const stored = localStorage.getItem(this.STORAGE_KEY); return stored ? JSON.parse(stored) : []; } catch (error) { console.error('Failed to load snippets from localStorage:', error); return []; } }, // Get single snippet by ID getSnippet(id) { const snippets = this.loadSnippets(); return snippets.find(snippet => snippet.id === id); }, // Save single snippet (add or update) saveSnippet(snippet) { const snippets = this.loadSnippets(); const existingIndex = snippets.findIndex(s => s.id === snippet.id); snippet.modified = new Date().toISOString(); if (existingIndex >= 0) { snippets[existingIndex] = snippet; } else { snippets.push(snippet); } return this.saveSnippets(snippets); }, // Delete snippet by ID deleteSnippet(id) { const snippets = this.loadSnippets(); const filteredSnippets = snippets.filter(snippet => snippet.id !== id); return this.saveSnippets(filteredSnippets); }, // Get all snippets with sorting and filtering listSnippets(sortBy = null, sortOrder = null, searchQuery = null) { let snippets = this.loadSnippets(); // Apply search filter if provided if (searchQuery && searchQuery.trim()) { snippets = this.filterSnippets(snippets, searchQuery.trim()); } // Use provided sort options or fall back to settings const actualSortBy = sortBy || AppSettings.get('sortBy') || 'modified'; const actualSortOrder = sortOrder || AppSettings.get('sortOrder') || 'desc'; return snippets.sort((a, b) => { let comparison = 0; switch (actualSortBy) { case 'name': comparison = a.name.localeCompare(b.name); break; case 'created': comparison = new Date(a.created) - new Date(b.created); break; case 'modified': default: comparison = new Date(a.modified) - new Date(b.modified); break; } return actualSortOrder === 'desc' ? -comparison : comparison; }); }, // Filter snippets based on search query filterSnippets(snippets, query) { const searchTerm = query.toLowerCase(); return snippets.filter(snippet => { // Search in name if (snippet.name.toLowerCase().includes(searchTerm)) { return true; } // Search in comment if (snippet.comment && snippet.comment.toLowerCase().includes(searchTerm)) { return true; } // Search in spec content (JSON stringified) try { const specText = JSON.stringify(snippet.draftSpec || snippet.spec).toLowerCase(); if (specText.includes(searchTerm)) { return true; } } catch (error) { // Ignore JSON stringify errors } return false; }); } }; // Initialize storage with default snippet if empty function initializeSnippetsStorage() { const existingSnippets = SnippetStorage.loadSnippets(); if (existingSnippets.length === 0) { // Create default snippet using the sample spec from config const defaultSnippet = createSnippet(sampleSpec, "Sample Bar Chart"); defaultSnippet.comment = "A simple bar chart showing category values"; SnippetStorage.saveSnippet(defaultSnippet); return [defaultSnippet]; } return existingSnippets; } // Format date for display in snippet list function formatSnippetDate(isoString) { const date = new Date(isoString); const diffDays = Math.floor((new Date() - date) / (1000 * 60 * 60 * 24)); if (diffDays === 0) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); if (diffDays === 1) return 'Yesterday'; if (diffDays < 7) return `${diffDays} days ago`; return date.toLocaleDateString(); } // Format full date/time for display in meta info function formatFullDate(isoString) { const date = new Date(isoString); return date.toLocaleString([], { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); } // Render snippet list in the UI function renderSnippetList(searchQuery = null) { // Get search query from input if not provided if (searchQuery === null) { const searchInput = document.getElementById('snippet-search'); searchQuery = searchInput ? searchInput.value : ''; } const snippets = SnippetStorage.listSnippets(null, null, searchQuery); const snippetList = document.querySelector('.snippet-list'); const placeholder = document.querySelector('.placeholder'); if (snippets.length === 0) { snippetList.innerHTML = ''; placeholder.style.display = 'block'; // Show different message for search vs empty state if (searchQuery && searchQuery.trim()) { placeholder.textContent = 'No snippets match your search'; } else { placeholder.textContent = 'No snippets found'; } return; } placeholder.style.display = 'none'; const ghostCard = `
  • + Create New Snippet
    Click to create
  • `; const currentSort = AppSettings.get('sortBy'); const snippetItems = snippets.map(snippet => { // Show appropriate date based on current sort let dateText; if (currentSort === 'created') { dateText = formatSnippetDate(snippet.created); } else { dateText = formatSnippetDate(snippet.modified); } return `
  • ${snippet.name}
    ${dateText}
  • `; }).join(''); snippetList.innerHTML = ghostCard + snippetItems; // Re-attach event listeners for snippet selection attachSnippetEventListeners(); } // Initialize sort controls function initializeSortControls() { const sortButtons = document.querySelectorAll('.sort-btn'); const currentSort = AppSettings.get('sortBy'); const currentOrder = AppSettings.get('sortOrder'); // Update active button and arrow based on settings sortButtons.forEach(button => { button.classList.remove('active'); if (button.dataset.sort === currentSort) { button.classList.add('active'); updateSortArrow(button, currentOrder); } else { updateSortArrow(button, 'desc'); // Default to desc for inactive buttons } // Add click handler button.addEventListener('click', function() { const sortType = this.dataset.sort; toggleSort(sortType); }); }); } // Update sort arrow display function updateSortArrow(button, direction) { const arrow = button.querySelector('.sort-arrow'); if (arrow) { arrow.textContent = direction === 'desc' ? '⬇' : '⬆'; } } // Toggle sort method and direction function toggleSort(sortType) { const currentSort = AppSettings.get('sortBy'); const currentOrder = AppSettings.get('sortOrder'); let newOrder; if (currentSort === sortType) { // Same button clicked - toggle direction newOrder = currentOrder === 'desc' ? 'asc' : 'desc'; } else { // Different button clicked - default to desc newOrder = 'desc'; } // Save to settings AppSettings.set('sortBy', sortType); AppSettings.set('sortOrder', newOrder); // Update button states and arrows document.querySelectorAll('.sort-btn').forEach(btn => { btn.classList.remove('active'); if (btn.dataset.sort === sortType) { btn.classList.add('active'); updateSortArrow(btn, newOrder); } else { updateSortArrow(btn, 'desc'); // Default for inactive buttons } }); // Re-render list renderSnippetList(); // Restore selection if there was one if (window.currentSnippetId) { const selectedItem = document.querySelector(`[data-snippet-id="${window.currentSnippetId}"]`); if (selectedItem) { selectedItem.classList.add('selected'); } } } // Initialize search controls function initializeSearchControls() { const searchInput = document.getElementById('snippet-search'); const clearButton = document.getElementById('search-clear'); if (searchInput) { // Debounced search on input let searchTimeout; searchInput.addEventListener('input', function() { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { performSearch(); }, 300); // 300ms debounce }); // Update clear button state searchInput.addEventListener('input', updateClearButton); } if (clearButton) { clearButton.addEventListener('click', clearSearch); // Initialize clear button state updateClearButton(); } } // Perform search and update display function performSearch() { const searchInput = document.getElementById('snippet-search'); if (!searchInput) return; renderSnippetList(searchInput.value); // Clear selection if current snippet is no longer visible if (window.currentSnippetId) { const selectedItem = document.querySelector(`[data-snippet-id="${window.currentSnippetId}"]`); if (!selectedItem) { clearSelection(); } else { selectedItem.classList.add('selected'); } } } // Clear search function clearSearch() { const searchInput = document.getElementById('snippet-search'); if (searchInput) { searchInput.value = ''; performSearch(); updateClearButton(); searchInput.focus(); } } // Update clear button state function updateClearButton() { const searchInput = document.getElementById('snippet-search'); const clearButton = document.getElementById('search-clear'); if (clearButton && searchInput) { clearButton.disabled = !searchInput.value.trim(); } } // Clear current selection and hide meta panel function clearSelection() { window.currentSnippetId = null; document.querySelectorAll('.snippet-item').forEach(item => { item.classList.remove('selected'); }); // Clear editor content if (editor) { window.isUpdatingEditor = true; editor.setValue('{}'); window.isUpdatingEditor = false; } // Hide meta panel and show placeholder const metaSection = document.getElementById('snippet-meta'); const placeholder = document.querySelector('.placeholder'); if (metaSection) metaSection.style.display = 'none'; if (placeholder) { placeholder.style.display = 'block'; placeholder.textContent = 'Click to select a snippet'; } } // Attach event listeners to snippet items function attachSnippetEventListeners() { const snippetItems = document.querySelectorAll('.snippet-item'); snippetItems.forEach(item => { // Handle ghost card for new snippet creation if (item.id === 'new-snippet-card') { item.addEventListener('click', function () { createNewSnippet(); }); return; } // Left click to select item.addEventListener('click', function () { const snippetId = parseFloat(this.dataset.snippetId); selectSnippet(snippetId); }); }); } // Select and load a snippet into the editor function selectSnippet(snippetId) { const snippet = SnippetStorage.getSnippet(snippetId); if (!snippet) return; // Update visual selection document.querySelectorAll('.snippet-item').forEach(item => { item.classList.remove('selected'); }); document.querySelector(`[data-snippet-id="${snippetId}"]`).classList.add('selected'); // Load draft spec into editor (prevent auto-save during update) if (editor) { window.isUpdatingEditor = true; editor.setValue(JSON.stringify(snippet.draftSpec, null, 2)); window.isUpdatingEditor = false; } // Show and populate meta fields const metaSection = document.getElementById('snippet-meta'); const nameField = document.getElementById('snippet-name'); const commentField = document.getElementById('snippet-comment'); const createdField = document.getElementById('snippet-created'); const modifiedField = document.getElementById('snippet-modified'); const placeholder = document.querySelector('.placeholder'); if (metaSection && nameField && commentField) { metaSection.style.display = 'block'; nameField.value = snippet.name || ''; commentField.value = snippet.comment || ''; // Format and display dates if (createdField) { createdField.textContent = formatFullDate(snippet.created); } if (modifiedField) { modifiedField.textContent = formatFullDate(snippet.modified); } placeholder.style.display = 'none'; } // Store currently selected snippet ID globally window.currentSnippetId = snippetId; } // Auto-save functionality let autoSaveTimeout; window.isUpdatingEditor = false; // Global flag to prevent auto-save/debounce during programmatic updates // Save current editor content as draft for the selected snippet function autoSaveDraft() { if (!window.currentSnippetId || !editor) return; try { const currentSpec = JSON.parse(editor.getValue()); const snippet = SnippetStorage.getSnippet(window.currentSnippetId); if (snippet) { snippet.draftSpec = currentSpec; SnippetStorage.saveSnippet(snippet); } } catch (error) { // Ignore JSON parse errors during editing } } // Debounced auto-save (triggered on editor changes) function debouncedAutoSave() { // Don't auto-save if we're programmatically updating the editor if (window.isUpdatingEditor) return; clearTimeout(autoSaveTimeout); autoSaveTimeout = setTimeout(autoSaveDraft, 1000); // 1 second delay } // Initialize auto-save on editor changes function initializeAutoSave() { // Initialize meta fields auto-save const nameField = document.getElementById('snippet-name'); const commentField = document.getElementById('snippet-comment'); if (nameField) { nameField.addEventListener('input', () => { debouncedAutoSaveMeta(); }); } if (commentField) { commentField.addEventListener('input', () => { debouncedAutoSaveMeta(); }); } // Initialize button event listeners const duplicateBtn = document.getElementById('duplicate-btn'); const deleteBtn = document.getElementById('delete-btn'); if (duplicateBtn) { duplicateBtn.addEventListener('click', () => { if (window.currentSnippetId) { duplicateSnippet(window.currentSnippetId); } }); } if (deleteBtn) { deleteBtn.addEventListener('click', () => { if (window.currentSnippetId) { deleteSnippet(window.currentSnippetId); } }); } } // Save meta fields (name and comment) for the selected snippet function autoSaveMeta() { if (!window.currentSnippetId) return; const nameField = document.getElementById('snippet-name'); const commentField = document.getElementById('snippet-comment'); if (!nameField || !commentField) return; const snippet = SnippetStorage.getSnippet(window.currentSnippetId); if (snippet) { snippet.name = nameField.value.trim() || generateSnippetName(); snippet.comment = commentField.value; SnippetStorage.saveSnippet(snippet); // Update the snippet list display to reflect the new name renderSnippetList(); // Restore selection after re-render const selectedItem = document.querySelector(`[data-snippet-id="${window.currentSnippetId}"]`); if (selectedItem) { selectedItem.classList.add('selected'); } } } // Debounced meta auto-save let metaAutoSaveTimeout; function debouncedAutoSaveMeta() { clearTimeout(metaAutoSaveTimeout); metaAutoSaveTimeout = setTimeout(autoSaveMeta, 1000); } // CRUD Operations // Create new snippet function createNewSnippet() { const emptySpec = { "$schema": "https://vega.github.io/schema/vega-lite/v5.json", "data": {"values": []}, "mark": "point", "encoding": {} }; const newSnippet = createSnippet(emptySpec); SnippetStorage.saveSnippet(newSnippet); // Refresh the list and select the new snippet renderSnippetList(); selectSnippet(newSnippet.id); return newSnippet; } // Duplicate existing snippet function duplicateSnippet(snippetId) { const originalSnippet = SnippetStorage.getSnippet(snippetId); if (!originalSnippet) return; const duplicateSpec = JSON.parse(JSON.stringify(originalSnippet.draftSpec)); const duplicateName = `${originalSnippet.name}_copy`; const newSnippet = createSnippet(duplicateSpec, duplicateName); newSnippet.comment = originalSnippet.comment; newSnippet.tags = [...originalSnippet.tags]; SnippetStorage.saveSnippet(newSnippet); // Refresh the list and select the new snippet renderSnippetList(); selectSnippet(newSnippet.id); return newSnippet; } // Delete snippet with confirmation function deleteSnippet(snippetId) { const snippet = SnippetStorage.getSnippet(snippetId); if (!snippet) return; if (confirm(`Delete snippet "${snippet.name}"? This action cannot be undone.`)) { SnippetStorage.deleteSnippet(snippetId); // If we deleted the currently selected snippet, clear selection if (window.currentSnippetId === snippetId) { clearSelection(); } // Refresh the list renderSnippetList(); return true; } return false; } // Rename snippet function renameSnippet(snippetId, newName) { const snippet = SnippetStorage.getSnippet(snippetId); if (!snippet) return false; snippet.name = newName.trim() || generateSnippetName(); SnippetStorage.saveSnippet(snippet); // Refresh the list to show new name renderSnippetList(); // Restore selection if this was the selected snippet if (window.currentSnippetId === snippetId) { document.querySelector(`[data-snippet-id="${snippetId}"]`).classList.add('selected'); } return true; }