// Snippet management and localStorage functionality // Storage limits (5MB in bytes) const STORAGE_LIMIT_BYTES = 5 * 1024 * 1024; // 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())}`; } // Nested spec keys for recursive traversal const NESTED_SPEC_KEYS = ['layer', 'concat', 'hconcat', 'vconcat', 'spec']; // Generic spec traversal helper - executes callback on each spec object // Returns first non-undefined result, or defaultReturn if no match found function traverseSpec(spec, callback, defaultReturn = null) { if (!spec || typeof spec !== 'object') return defaultReturn; const result = callback(spec); if (result !== undefined) return result; for (const key of NESTED_SPEC_KEYS) { if (Array.isArray(spec[key])) { for (const item of spec[key]) { const result = traverseSpec(item, callback, defaultReturn); if (result !== undefined) return result; } } else if (spec[key] && typeof spec[key] === 'object') { const result = traverseSpec(spec[key], callback, defaultReturn); if (result !== undefined) return result; } } return defaultReturn; } // Extract dataset references from Vega-Lite spec function extractDatasetRefs(spec) { const datasetNames = new Set(); function traverse(obj) { if (!obj || typeof obj !== 'object') return; // Check if this is a data object with a name property if (obj.data && typeof obj.data === 'object' && obj.data.name) { datasetNames.add(obj.data.name); } // Recursively check all properties for (const key in obj) { if (obj.hasOwnProperty(key)) { traverse(obj[key]); } } } traverse(spec); return Array.from(datasetNames); } // Detect if spec has inline data (data.values) function hasInlineData(spec) { return traverseSpec(spec, (s) => { if (s.data && s.data.values) { if (Array.isArray(s.data.values) || typeof s.data.values === 'string') { return true; } } return undefined; }, false) === true; } // Extract inline data from spec (finds first occurrence) function extractInlineDataFromSpec(spec) { return traverseSpec(spec, (s) => { if (s.data && s.data.values) { if (Array.isArray(s.data.values) || typeof s.data.values === 'string') { return s.data.values; } } return undefined; }, null); } // Detect inline data format from spec function detectInlineDataFormat(spec) { return traverseSpec(spec, (s) => { if (s.data && s.data.format && s.data.format.type) { const formatType = s.data.format.type.toLowerCase(); if (formatType === 'csv' || formatType === 'tsv' || formatType === 'json' || formatType === 'topojson') { return formatType; } } return undefined; }, 'json'); } // Replace inline data with dataset reference function replaceInlineDataWithReference(spec, datasetName) { if (!spec || typeof spec !== 'object') return spec; // Clone the spec to avoid mutation const newSpec = JSON.parse(JSON.stringify(spec)); let replaced = false; // Traverse and replace first occurrence of inline data (function traverseAndReplace(obj) { if (replaced || !obj || typeof obj !== 'object') return; if (obj.data && obj.data.values && (Array.isArray(obj.data.values) || typeof obj.data.values === 'string')) { obj.data = { name: datasetName }; replaced = true; return; } for (const key of NESTED_SPEC_KEYS) { if (replaced) return; if (Array.isArray(obj[key])) { for (const item of obj[key]) { traverseAndReplace(item); if (replaced) return; } } else if (obj[key] && typeof obj[key] === 'object') { traverseAndReplace(obj[key]); } } })(newSpec); return newSpec; } // 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)); updateStorageMonitor(); return true; } catch (error) { console.error('Failed to save snippets to localStorage:', error); Toast.error('Failed to save: Storage quota may be exceeded. Consider deleting old snippets.'); 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 'size': // Calculate size for both snippets const sizeA = new Blob([JSON.stringify(a)]).size; const sizeB = new Blob([JSON.stringify(b)]).size; comparison = sizeA - sizeB; 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 sample data from JSON file if empty async function initializeSnippetsStorage() { const existingSnippets = SnippetStorage.loadSnippets(); if (existingSnippets.length === 0) { // Try loading sample data from JSON file try { const response = await fetch('sample-data.json'); if (response.ok) { const sampleData = await response.json(); const result = await processImportedData(sampleData, { silent: true }); if (result.success) { return result.normalizedSnippets; } } } catch (error) { console.warn('Failed to load sample-data.json, using fallback:', error); } // Fallback: 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 (delegates to user-settings.js) function formatSnippetDate(isoString) { return formatDate(isoString, false); } // Format full date/time for display in meta info (delegates to user-settings.js) function formatFullDate(isoString) { return formatDate(isoString, true); } // 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 placeholder = document.querySelector('.placeholder'); // Handle empty state with placeholder if (snippets.length === 0) { document.querySelector('.snippet-list').innerHTML = ''; placeholder.style.display = 'block'; placeholder.textContent = searchQuery && searchQuery.trim() ? 'No snippets match your search' : 'No snippets found'; return; } placeholder.style.display = 'none'; const currentSort = AppSettings.get('sortBy'); // Format individual snippet items const formatSnippetItem = (snippet) => { // Show appropriate date based on current sort const dateText = currentSort === 'created' ? formatSnippetDate(snippet.created) : formatSnippetDate(snippet.modified); // Calculate snippet size const snippetSize = new Blob([JSON.stringify(snippet)]).size; const sizeKB = snippetSize / 1024; const sizeHTML = sizeKB >= 1 ? `${sizeKB.toFixed(0)} KB` : ''; // Determine status: green if no draft changes, yellow if has draft const hasDraft = JSON.stringify(snippet.spec) !== JSON.stringify(snippet.draftSpec); const statusClass = hasDraft ? 'draft' : 'published'; // Check if snippet uses external datasets const usesDatasets = snippet.datasetRefs && snippet.datasetRefs.length > 0; const datasetIconHTML = usesDatasets ? '📁' : ''; return `
  • ${snippet.name}${datasetIconHTML}
    ${dateText}
    ${sizeHTML}
  • `; }; // Ghost card for creating new snippets const ghostCard = `
  • + Create New Snippet
    Click to create
  • `; // Use generic list renderer renderGenericList('snippet-list', snippets, formatSnippetItem, selectSnippet, { ghostCard: ghostCard, onGhostCardClick: createNewSnippet, itemSelector: '.snippet-item' }); } // 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 restoreSnippetSelection(); } // 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-item-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(); } } // Helper: Get currently selected snippet function getCurrentSnippet() { return window.currentSnippetId ? SnippetStorage.getSnippet(window.currentSnippetId) : null; } // Helper: Restore visual selection state for current snippet function restoreSnippetSelection() { if (window.currentSnippetId) { const item = document.querySelector(`[data-item-id="${window.currentSnippetId}"]`); if (item) { item.classList.add('selected'); return item; } } return null; } // 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'; } } // Select and load a snippet into the editor function selectSnippet(snippetId, updateURL = true) { const snippet = SnippetStorage.getSnippet(snippetId); if (!snippet) return; // Update visual selection document.querySelectorAll('.snippet-item').forEach(item => { item.classList.remove('selected'); }); const selectedItem = document.querySelector(`[data-item-id="${snippetId}"]`); if (selectedItem) { selectedItem.classList.add('selected'); } // Load spec based on current view mode loadSnippetIntoEditor(snippet); updateViewModeUI(snippet); // 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; // Update linked datasets display updateLinkedDatasets(snippet); // Update Extract to Dataset button visibility updateExtractButton(); // Update URL state (URLState.update will add 'snippet-' prefix) if (updateURL) { URLState.update({ view: 'snippets', snippetId: snippetId, datasetId: null }); } } // Update linked datasets display in metadata panel function updateLinkedDatasets(snippet) { const datasetRefs = snippet.datasetRefs || []; updateGenericLinkedItems( datasetRefs, 'snippet-datasets', 'snippet-datasets-section', (datasetName) => `
    📁 ${datasetName}
    `, async (datasetName) => { await openDatasetByName(datasetName); } ); } // Open dataset manager and select dataset by name async function openDatasetByName(datasetName) { // Open dataset manager modal openDatasetManager(); // Wait for datasets to load and find the one with matching name // We need to use DatasetStorage which is defined in dataset-manager.js try { const dataset = await DatasetStorage.getDatasetByName(datasetName); if (dataset) { // Small delay to ensure UI is ready setTimeout(() => { selectDataset(dataset.id); }, 100); } else { Toast.error(`Dataset "${datasetName}" not found. It may have been deleted.`); } } catch (error) { console.error('Error opening dataset:', error); Toast.error(`Could not open dataset "${datasetName}".`); } } // 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; // Only save to draft if we're in draft mode if (currentViewMode !== 'draft') return; try { const currentSpec = JSON.parse(editor.getValue()); const snippet = getCurrentSnippet(); if (snippet) { snippet.draftSpec = currentSpec; // Extract and update dataset references snippet.datasetRefs = extractDatasetRefs(currentSpec); SnippetStorage.saveSnippet(snippet); // Refresh snippet list to update status light and dataset indicator renderSnippetList(); // Restore selection restoreSnippetSelection(); // Update button states updateViewModeUI(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; // If viewing published and no draft exists, create draft automatically if (currentViewMode === 'published') { const snippet = getCurrentSnippet(); if (snippet) { const hasDraft = JSON.stringify(snippet.spec) !== JSON.stringify(snippet.draftSpec); if (!hasDraft) { // No draft exists, automatically switch to draft mode currentViewMode = 'draft'; updateViewModeUI(snippet); editor.updateOptions({ readOnly: false }); } } } 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() { const nameField = document.getElementById('snippet-name'); const commentField = document.getElementById('snippet-comment'); if (!nameField || !commentField) return; const snippet = getCurrentSnippet(); 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 restoreSnippetSelection(); } } // 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); // Track event Analytics.track('snippet-create', 'Create new snippet'); 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]; newSnippet.datasetRefs = extractDatasetRefs(duplicateSpec); SnippetStorage.saveSnippet(newSnippet); // Refresh the list and select the new snippet renderSnippetList(); selectSnippet(newSnippet.id); // Show success message Toast.success('Snippet duplicated successfully'); // Track event Analytics.track('snippet-duplicate', 'Duplicate snippet'); return newSnippet; } // Create new snippet from dataset with minimal spec function createSnippetFromDataset(datasetName) { const minimalSpec = { "$schema": "https://vega.github.io/schema/vega-lite/v5.json", "data": {"name": datasetName}, "mark": {"type": "point", "tooltip": true}, "encoding": {} }; const newSnippet = createSnippet(minimalSpec); newSnippet.comment = `Visualization using dataset: ${datasetName}`; newSnippet.datasetRefs = [datasetName]; SnippetStorage.saveSnippet(newSnippet); // Refresh the list and select the new snippet renderSnippetList(); selectSnippet(newSnippet.id); // Track event Analytics.track('snippet-from-dataset', 'Create snippet from dataset'); return newSnippet; } // Show extract to dataset modal function showExtractModal() { const snippet = getCurrentSnippet(); if (!snippet) return; // Get the draft spec (most recent version) const spec = snippet.draftSpec; // Check if spec has inline data if (!hasInlineData(spec)) { Toast.info('No inline data found in this snippet.'); return; } // Extract the inline data and its format const inlineData = extractInlineDataFromSpec(spec); if (!inlineData || (Array.isArray(inlineData) && inlineData.length === 0) || (typeof inlineData === 'string' && inlineData.trim() === '')) { Toast.warning('No inline data could be extracted.'); return; } // Generate default dataset name from snippet name const defaultName = `${snippet.name}_data`.replace(/[^a-zA-Z0-9_-]/g, '_'); // Show modal const modal = document.getElementById('extract-modal'); const nameInput = document.getElementById('extract-dataset-name'); const previewEl = document.getElementById('extract-data-preview'); const errorEl = document.getElementById('extract-form-error'); nameInput.value = defaultName; // Generate preview based on data type if (typeof inlineData === 'string') { // CSV/TSV data - show first few lines const lines = inlineData.trim().split('\n'); const previewLines = lines.slice(0, 11); // Header + 10 data rows previewEl.textContent = previewLines.join('\n'); if (lines.length > 11) { previewEl.textContent += `\n\n... (${lines.length - 11} more rows)`; } } else { // JSON data (array) previewEl.textContent = JSON.stringify(inlineData.slice(0, 10), null, 2); if (inlineData.length > 10) { previewEl.textContent += `\n\n... (${inlineData.length - 10} more rows)`; } } errorEl.textContent = ''; modal.style.display = 'flex'; } // Hide extract to dataset modal function hideExtractModal() { const modal = document.getElementById('extract-modal'); modal.style.display = 'none'; } // Extract to dataset - create dataset and update snippet async function extractToDataset() { const snippet = getCurrentSnippet(); if (!snippet) return; const nameInput = document.getElementById('extract-dataset-name'); const errorEl = document.getElementById('extract-form-error'); const datasetName = nameInput.value.trim(); errorEl.textContent = ''; // Validation if (!datasetName) { errorEl.textContent = 'Dataset name is required'; return; } // Check if dataset name already exists if (await DatasetStorage.nameExists(datasetName)) { errorEl.textContent = 'A dataset with this name already exists'; return; } // Extract inline data from draft spec const inlineData = extractInlineDataFromSpec(snippet.draftSpec); if (!inlineData) { errorEl.textContent = 'Could not extract inline data'; return; } try { // Detect the data format (json, csv, tsv, etc.) const format = detectInlineDataFormat(snippet.draftSpec); // Create dataset in IndexedDB await DatasetStorage.createDataset(datasetName, inlineData, format, 'inline', `Extracted from snippet: ${snippet.name}`); // Replace inline data with dataset reference in draft spec snippet.draftSpec = replaceInlineDataWithReference(snippet.draftSpec, datasetName); // Update dataset references snippet.datasetRefs = extractDatasetRefs(snippet.draftSpec); // Save snippet SnippetStorage.saveSnippet(snippet); // Update editor with new spec if (editor && currentViewMode === 'draft') { window.isUpdatingEditor = true; editor.setValue(JSON.stringify(snippet.draftSpec, null, 2)); window.isUpdatingEditor = false; } // Refresh UI renderSnippetList(); restoreSnippetSelection(); updateLinkedDatasets(snippet); updateViewModeUI(snippet); updateExtractButton(); // Close modal hideExtractModal(); // Show success message Toast.success(`Dataset "${datasetName}" created successfully!`); // Track event Analytics.track('dataset-extract', 'Extract inline data to dataset'); } catch (error) { errorEl.textContent = `Failed to create dataset: ${error.message}`; } } // Update visibility of Extract to Dataset button function updateExtractButton() { const extractBtn = document.getElementById('extract-btn'); if (!extractBtn) return; const snippet = getCurrentSnippet(); if (!snippet) { extractBtn.style.display = 'none'; return; } // Check if draft spec has inline data const hasInline = hasInlineData(snippet.draftSpec); extractBtn.style.display = hasInline ? 'block' : 'none'; } // Delete snippet with confirmation function deleteSnippet(snippetId) { const snippet = SnippetStorage.getSnippet(snippetId); if (!snippet) return; const confirmed = confirmGenericDeletion(snippet.name, null, () => { SnippetStorage.deleteSnippet(snippetId); // If we deleted the currently selected snippet, clear selection if (window.currentSnippetId === snippetId) { clearSelection(); } // Refresh the list renderSnippetList(); // Show success message Toast.success('Snippet deleted'); // Track event trackEventIfAvailable('snippet-delete', 'Delete snippet'); }); return confirmed; } // Load snippet into editor based on view mode function loadSnippetIntoEditor(snippet) { if (!editor) return; window.isUpdatingEditor = true; const hasDraft = JSON.stringify(snippet.spec) !== JSON.stringify(snippet.draftSpec); if (currentViewMode === 'draft') { editor.setValue(JSON.stringify(snippet.draftSpec, null, 2)); editor.updateOptions({ readOnly: false }); } else { // Published view - always read-only if draft exists editor.setValue(JSON.stringify(snippet.spec, null, 2)); editor.updateOptions({ readOnly: hasDraft }); } window.isUpdatingEditor = false; } // Update view mode UI (buttons and editor state) function updateViewModeUI(snippet) { const draftBtn = document.getElementById('view-draft'); const publishedBtn = document.getElementById('view-published'); const publishBtn = document.getElementById('publish-btn'); const revertBtn = document.getElementById('revert-btn'); // Update toggle button states if (currentViewMode === 'draft') { draftBtn.classList.add('active'); publishedBtn.classList.remove('active'); } else { draftBtn.classList.remove('active'); publishedBtn.classList.add('active'); } // Show/hide and enable/disable action buttons based on mode const hasDraft = JSON.stringify(snippet.spec) !== JSON.stringify(snippet.draftSpec); if (currentViewMode === 'draft') { // In draft mode: show both buttons, enable based on draft existence publishBtn.classList.add('visible'); revertBtn.classList.add('visible'); publishBtn.disabled = !hasDraft; revertBtn.disabled = !hasDraft; } else { // In published mode: hide both buttons publishBtn.classList.remove('visible'); revertBtn.classList.remove('visible'); } } // Switch view mode function switchViewMode(mode) { currentViewMode = mode; const snippet = getCurrentSnippet(); if (snippet) { loadSnippetIntoEditor(snippet); updateViewModeUI(snippet); } } // Publish draft to spec function publishDraft() { const snippet = getCurrentSnippet(); if (!snippet) return; // Copy draftSpec to spec snippet.spec = JSON.parse(JSON.stringify(snippet.draftSpec)); // Update dataset references for published spec snippet.datasetRefs = extractDatasetRefs(snippet.spec); SnippetStorage.saveSnippet(snippet); // Refresh UI renderSnippetList(); restoreSnippetSelection(); updateViewModeUI(snippet); // Show success message Toast.success('Snippet published successfully!'); // Track event Analytics.track('snippet-publish', 'Publish draft'); } // Revert draft to published spec function revertDraft() { const snippet = getCurrentSnippet(); if (!snippet) return; if (confirm('Revert all draft changes to last published version? This cannot be undone.')) { // Copy spec to draftSpec snippet.draftSpec = JSON.parse(JSON.stringify(snippet.spec)); SnippetStorage.saveSnippet(snippet); // Reload editor if in draft view if (currentViewMode === 'draft') { loadSnippetIntoEditor(snippet); } // Refresh UI renderSnippetList(); restoreSnippetSelection(); updateViewModeUI(snippet); // Show success message Toast.success('Draft reverted to published version'); // Track event Analytics.track('snippet-revert', 'Revert draft'); } } // Calculate storage usage in bytes function calculateStorageUsage() { const snippetsData = localStorage.getItem(SnippetStorage.STORAGE_KEY); if (!snippetsData) return 0; // Calculate size in bytes return new Blob([snippetsData]).size; } // Update storage monitor display function updateStorageMonitor() { const usedBytes = calculateStorageUsage(); const percentage = (usedBytes / STORAGE_LIMIT_BYTES) * 100; const storageText = document.getElementById('storage-text'); const storageFill = document.getElementById('storage-fill'); if (storageText) { storageText.textContent = `${formatBytes(usedBytes)} / 5 MB`; } if (storageFill) { storageFill.style.width = `${Math.min(percentage, 100)}%`; // Remove all state classes storageFill.classList.remove('warning', 'critical'); // Add warning/critical classes based on usage if (percentage >= 95) { storageFill.classList.add('critical'); } else if (percentage >= 90) { storageFill.classList.add('warning'); } } } // Export all snippets and datasets to JSON file async function exportSnippets() { const snippets = SnippetStorage.loadSnippets(); if (snippets.length === 0) { Toast.info('No snippets to export'); return; } // Get ALL datasets for complete backup const datasets = await DatasetStorage.listDatasets(); // Create unified export format const exportData = { version: "1.0", exportedAt: new Date().toISOString(), exportedBy: "Astrolabe", snippets: snippets, datasets: datasets }; // Create JSON blob const jsonString = JSON.stringify(exportData, null, 2); const blob = new Blob([jsonString], { type: 'application/json' }); // Create download link const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `astrolabe-project-${new Date().toISOString().slice(0, 10)}.json`; // Trigger download document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); // Show success message const count = datasets.length > 0 ? `${snippets.length} snippet${snippets.length !== 1 ? 's' : ''} and ${datasets.length} dataset${datasets.length !== 1 ? 's' : ''}` : `${snippets.length} snippet${snippets.length !== 1 ? 's' : ''}`; Toast.success(`Exported ${count}`); // Track event Analytics.track('project-export', `Export ${snippets.length} snippets, ${datasets.length} datasets`); } // Normalize external snippet format to Astrolabe format function normalizeSnippet(externalSnippet) { // Check if already in Astrolabe format (has 'created' field as ISO string) const isAstrolabeFormat = externalSnippet.created && typeof externalSnippet.created === 'string' && externalSnippet.created.includes('T'); if (isAstrolabeFormat) { // Already in correct format, just ensure all fields exist return { id: externalSnippet.id || generateSnippetId(), name: externalSnippet.name || generateSnippetName(), created: externalSnippet.created, modified: externalSnippet.modified || externalSnippet.created, spec: externalSnippet.spec || {}, draftSpec: externalSnippet.draftSpec || externalSnippet.spec || {}, comment: externalSnippet.comment || "", tags: externalSnippet.tags || [], datasetRefs: externalSnippet.datasetRefs || [], meta: externalSnippet.meta || {} }; } // External format - map fields const createdDate = externalSnippet.createdAt ? new Date(externalSnippet.createdAt).toISOString() : new Date().toISOString(); return { id: generateSnippetId(), // Generate new ID to avoid conflicts name: externalSnippet.name || generateSnippetName(), created: createdDate, modified: createdDate, spec: externalSnippet.content || externalSnippet.spec || {}, draftSpec: externalSnippet.draft || externalSnippet.draftSpec || externalSnippet.content || externalSnippet.spec || {}, comment: externalSnippet.comment || "", tags: ["imported"], // Add 'imported' tag datasetRefs: [], meta: {} }; } // Calculate size of data in bytes function calculateDataSize(data) { return new Blob([JSON.stringify(data)]).size; } // Estimate if import would fit in storage (before attempting to save) function estimateImportFit(existingSnippets, newSnippets) { const currentSize = calculateDataSize(existingSnippets); const newDataSize = calculateDataSize(newSnippets); const totalSize = currentSize + newDataSize; const available = STORAGE_LIMIT_BYTES - currentSize; return { currentSize: currentSize, newDataSize: newDataSize, totalSize: totalSize, available: available, willFit: totalSize <= STORAGE_LIMIT_BYTES, overageBytes: Math.max(0, totalSize - STORAGE_LIMIT_BYTES) }; } // Core logic to process imported data (shared between file import and initial sample data) async function processImportedData(importedData, options = {}) { const { silent = false } = options; // Detect format: legacy (array) or unified (object with version) let snippetsToImport = []; let datasetsToImport = []; if (Array.isArray(importedData)) { // Legacy format: array of snippets only snippetsToImport = importedData; } else if (importedData.version && importedData.snippets) { // New unified format snippetsToImport = importedData.snippets || []; datasetsToImport = importedData.datasets || []; } else { // Single snippet object snippetsToImport = [importedData]; } if (snippetsToImport.length === 0) { if (!silent) Toast.info('No snippets found in file'); return { success: false }; } // Import datasets first (if any) let datasetsImported = 0; const renamedDatasets = []; // Track renamed datasets for warning for (const datasetData of datasetsToImport) { try { let datasetName = datasetData.name; const originalName = datasetName; // Handle name conflicts by renaming if (await DatasetStorage.nameExists(datasetName)) { const timestamp = Date.now().toString().slice(-6); datasetName = `${originalName}_${timestamp}`; // Unlikely, but ensure uniqueness let counter = 1; while (await DatasetStorage.nameExists(datasetName)) { datasetName = `${originalName}_${timestamp}_${counter}`; counter++; } renamedDatasets.push({ from: originalName, to: datasetName }); } await DatasetStorage.createDataset( datasetName, datasetData.data, datasetData.format, datasetData.source, datasetData.comment || '' ); datasetsImported++; } catch (error) { console.warn(`Failed to import dataset ${datasetData.name}:`, error); } } // Import snippets (existing normalization logic) const existingSnippets = SnippetStorage.loadSnippets(); const existingIds = new Set(existingSnippets.map(s => s.id)); let snippetsImported = 0; const normalizedSnippets = []; snippetsToImport.forEach(snippet => { const normalized = normalizeSnippet(snippet); // Ensure no ID conflicts while (existingIds.has(normalized.id)) { normalized.id = generateSnippetId(); } normalizedSnippets.push(normalized); existingIds.add(normalized.id); snippetsImported++; }); // Estimate storage fit const fit = estimateImportFit(existingSnippets, normalizedSnippets); if (!fit.willFit && !silent) { Toast.warning( `⚠️ Import is ${formatBytes(fit.overageBytes)} over the 5 MB limit. Attempting to load...`, 5000 ); } // Save snippets const allSnippets = existingSnippets.concat(normalizedSnippets); if (SnippetStorage.saveSnippets(allSnippets)) { if (!silent) { let message = `Imported ${snippetsImported} snippet${snippetsImported !== 1 ? 's' : ''}`; if (datasetsImported > 0) { message += ` and ${datasetsImported} dataset${datasetsImported !== 1 ? 's' : ''}`; } // Warn about renamed datasets if (renamedDatasets.length > 0) { const renameList = renamedDatasets.map(r => `"${r.from}" → "${r.to}"`).join(', '); Toast.warning( `${message}. Some datasets were renamed due to conflicts: ${renameList}. You may need to update dataset references in affected snippets.`, 8000 ); } else { Toast.success(message); } // Track event Analytics.track('project-import', `Import ${snippetsImported} snippets, ${datasetsImported} datasets`); } renderSnippetList(); updateStorageMonitor(); return { success: true, snippetsImported, datasetsImported, normalizedSnippets }; } else { const overageBytes = fit.overageBytes > 0 ? fit.overageBytes : calculateDataSize(allSnippets) - STORAGE_LIMIT_BYTES; if (!silent) { Toast.error( `Storage quota exceeded by ${formatBytes(overageBytes)}. Please delete some snippets and try again.`, 6000 ); } return { success: false }; } } // Import snippets and datasets from JSON file function importSnippets(fileInput) { const file = fileInput.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = async function(e) { try { const importedData = JSON.parse(e.target.result); await processImportedData(importedData); } catch (error) { console.error('Import error:', error); Toast.error('Failed to import. Please check that the file is valid JSON.'); } // Clear file input fileInput.value = ''; }; reader.readAsText(file); }