mirror of
https://github.com/olehomelchenko/astrolabe-nvc.git
synced 2025-12-21 21:22:23 +00:00
feat: enhance snippet management with import/export functionality and size display
This commit is contained in:
@@ -15,7 +15,7 @@ Instructions for Claude Code when working on this project.
|
||||
|
||||
## Current Status
|
||||
|
||||
**Completed**: Phases 0-8 (All core functionality including storage monitoring)
|
||||
**Next**: Phase 9 - Export/Import
|
||||
**Completed**: Phases 0-9 (All core functionality including import/export)
|
||||
**Next**: Phase 10 - Dataset Management
|
||||
|
||||
See `docs/dev-plan.md` for complete roadmap and technical details.
|
||||
|
||||
@@ -170,16 +170,22 @@ Astrolabe is a focused tool for managing, editing, and previewing Vega-Lite visu
|
||||
|
||||
---
|
||||
|
||||
### **Phase 9: Export/Import**
|
||||
### **Phase 9: Export/Import** ✅ **COMPLETE**
|
||||
**Goal**: Portability and backup
|
||||
|
||||
- [ ] Export single snippet as JSON file (include metadata)
|
||||
- [ ] Export all snippets as JSON bundle
|
||||
- [ ] Import snippets from JSON (with conflict resolution)
|
||||
- [ ] Drag-and-drop import
|
||||
- [ ] Export published vs draft options
|
||||
**Deliverables**:
|
||||
- Export all snippets to JSON file with auto-generated filename (astrolabe-snippets-YYYY-MM-DD.json)
|
||||
- Import snippets from JSON with automatic format detection
|
||||
- Support for both Astrolabe native format and external formats
|
||||
- Automatic field mapping and normalization (createdAt → created, content → spec, draft → draftSpec)
|
||||
- "imported" tag automatically added to externally imported snippets
|
||||
- ID conflict resolution with automatic regeneration
|
||||
- Additive import (merges with existing snippets, no overwrites)
|
||||
- Success/error feedback with import count
|
||||
- File picker integration with header Import/Export links
|
||||
- Snippet size display in list (shows KB for snippets ≥ 1 KB, right-aligned)
|
||||
|
||||
**Deliverable**: Full backup/restore capability
|
||||
**Note**: Drag-and-drop import and selective export options deferred to future phases
|
||||
|
||||
---
|
||||
|
||||
@@ -305,15 +311,15 @@ Astrolabe is a focused tool for managing, editing, and previewing Vega-Lite visu
|
||||
|
||||
## Current Status
|
||||
|
||||
**Completed**: Phases 0-8 (Storage, UI, editor, rendering, persistence, CRUD, organization, draft/published workflow, storage monitoring)
|
||||
**Next**: Phase 9 - Export/Import
|
||||
**Completed**: Phases 0-9 (Storage, UI, editor, rendering, persistence, CRUD, organization, draft/published workflow, storage monitoring, import/export)
|
||||
**Next**: Phase 10 - Dataset Management
|
||||
**See**: `CLAUDE.md` for concise current state summary
|
||||
|
||||
---
|
||||
|
||||
## Implemented Features
|
||||
|
||||
### Core Capabilities (Phases 0-8)
|
||||
### Core Capabilities (Phases 0-9)
|
||||
- Three-panel resizable layout with memory and persistence
|
||||
- Monaco Editor v0.47.0 with Vega-Lite v5 schema validation
|
||||
- Live Vega-Lite rendering with debounced updates and error display
|
||||
@@ -326,6 +332,8 @@ Astrolabe is a focused tool for managing, editing, and previewing Vega-Lite visu
|
||||
- Status indicator lights (green/yellow) showing draft state
|
||||
- Context-aware Publish/Revert buttons with color coding
|
||||
- Storage monitoring with visual progress bar and warning states
|
||||
- Export/Import functionality with format auto-detection
|
||||
- Snippet size display (right-aligned, shown for ≥ 1 KB)
|
||||
- Retro Windows 2000 aesthetic throughout
|
||||
|
||||
### Technical Implementation
|
||||
@@ -339,3 +347,5 @@ Astrolabe is a focused tool for managing, editing, and previewing Vega-Lite visu
|
||||
- **Data Model**: Phase 0 schema with `spec` (published) and `draftSpec` (working) fields
|
||||
- **Storage Calculation**: Blob API for accurate byte counting of snippet data
|
||||
- **Flexbox Layout**: Scrollable snippet list with fixed metadata and storage monitor at bottom
|
||||
- **Import/Export**: Format detection, field normalization, ID conflict resolution, additive merging
|
||||
- **Size Display**: Per-snippet size calculation with conditional rendering (≥ 1 KB threshold)
|
||||
@@ -19,9 +19,10 @@
|
||||
<span class="header-title">Astrolabe</span>
|
||||
</div>
|
||||
<div class="header-links">
|
||||
<span class="header-link">Import</span>
|
||||
<span class="header-link">Export</span>
|
||||
<span class="header-link">Help</span>
|
||||
<span class="header-link" id="import-link">Import</span>
|
||||
<span class="header-link" id="export-link">Export</span>
|
||||
<span class="header-link" id="help-link">Help</span>
|
||||
<input type="file" id="import-file-input" accept=".json" style="display: none;" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -89,12 +89,33 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
});
|
||||
});
|
||||
|
||||
// Header links - show placeholder
|
||||
document.querySelectorAll('.header-link').forEach(link => {
|
||||
link.addEventListener('click', function () {
|
||||
// Header links
|
||||
const importLink = document.getElementById('import-link');
|
||||
const exportLink = document.getElementById('export-link');
|
||||
const helpLink = document.getElementById('help-link');
|
||||
const importFileInput = document.getElementById('import-file-input');
|
||||
|
||||
if (importLink && importFileInput) {
|
||||
importLink.addEventListener('click', function () {
|
||||
importFileInput.click();
|
||||
});
|
||||
|
||||
importFileInput.addEventListener('change', function () {
|
||||
importSnippets(this);
|
||||
});
|
||||
}
|
||||
|
||||
if (exportLink) {
|
||||
exportLink.addEventListener('click', function () {
|
||||
exportSnippets();
|
||||
});
|
||||
}
|
||||
|
||||
if (helpLink) {
|
||||
helpLink.addEventListener('click', function () {
|
||||
alert('Coming soon in a future phase!');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// View mode toggle buttons
|
||||
document.getElementById('view-draft').addEventListener('click', () => {
|
||||
|
||||
@@ -239,6 +239,11 @@ function renderSnippetList(searchQuery = null) {
|
||||
dateText = formatSnippetDate(snippet.modified);
|
||||
}
|
||||
|
||||
// Calculate snippet size
|
||||
const snippetSize = new Blob([JSON.stringify(snippet)]).size;
|
||||
const sizeKB = snippetSize / 1024;
|
||||
const sizeHTML = sizeKB >= 1 ? `<span class="snippet-size">${sizeKB.toFixed(0)} KB</span>` : '';
|
||||
|
||||
// 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';
|
||||
@@ -249,6 +254,7 @@ function renderSnippetList(searchQuery = null) {
|
||||
<div class="snippet-name">${snippet.name}</div>
|
||||
<div class="snippet-date">${dateText}</div>
|
||||
</div>
|
||||
${sizeHTML}
|
||||
<div class="snippet-status ${statusClass}"></div>
|
||||
</li>
|
||||
`;
|
||||
@@ -846,3 +852,125 @@ function updateStorageMonitor() {
|
||||
}
|
||||
}
|
||||
|
||||
// Export all snippets to JSON file
|
||||
function exportSnippets() {
|
||||
const snippets = SnippetStorage.loadSnippets();
|
||||
|
||||
if (snippets.length === 0) {
|
||||
alert('No snippets to export');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create JSON blob
|
||||
const jsonString = JSON.stringify(snippets, 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-snippets-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// 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: {}
|
||||
};
|
||||
}
|
||||
|
||||
// Import snippets from JSON file
|
||||
function importSnippets(fileInput) {
|
||||
const file = fileInput.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
try {
|
||||
const importedData = JSON.parse(e.target.result);
|
||||
|
||||
// Handle both single snippet and array of snippets
|
||||
const snippetsToImport = Array.isArray(importedData) ? importedData : [importedData];
|
||||
|
||||
if (snippetsToImport.length === 0) {
|
||||
alert('No snippets found in file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize and merge with existing snippets
|
||||
const existingSnippets = SnippetStorage.loadSnippets();
|
||||
const existingIds = new Set(existingSnippets.map(s => s.id));
|
||||
|
||||
let importedCount = 0;
|
||||
snippetsToImport.forEach(snippet => {
|
||||
const normalized = normalizeSnippet(snippet);
|
||||
|
||||
// Ensure no ID conflicts
|
||||
while (existingIds.has(normalized.id)) {
|
||||
normalized.id = generateSnippetId();
|
||||
}
|
||||
|
||||
existingSnippets.push(normalized);
|
||||
existingIds.add(normalized.id);
|
||||
importedCount++;
|
||||
});
|
||||
|
||||
// Save all snippets
|
||||
if (SnippetStorage.saveSnippets(existingSnippets)) {
|
||||
alert(`Successfully imported ${importedCount} snippet${importedCount !== 1 ? 's' : ''}`);
|
||||
renderSnippetList();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Import error:', error);
|
||||
alert('Failed to import snippets. Please check that the file is valid JSON.');
|
||||
}
|
||||
|
||||
// Clear file input
|
||||
fileInput.value = '';
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
|
||||
@@ -458,6 +458,14 @@ body {
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.snippet-size {
|
||||
font-size: 10px;
|
||||
color: #808080;
|
||||
margin-left: auto;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-placeholder {
|
||||
background: #ffffff;
|
||||
border: 2px inset #c0c0c0;
|
||||
|
||||
Reference in New Issue
Block a user