diff --git a/CLAUDE.md b/CLAUDE.md
index e2e6dd3..1778add 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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.
diff --git a/docs/dev-plan.md b/docs/dev-plan.md
index a3cc675..b8dc88c 100644
--- a/docs/dev-plan.md
+++ b/docs/dev-plan.md
@@ -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
@@ -338,4 +346,6 @@ Astrolabe is a focused tool for managing, editing, and previewing Vega-Lite visu
- **Panel Memory**: localStorage persistence for sizes and visibility across sessions
- **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
\ No newline at end of file
+- **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)
\ No newline at end of file
diff --git a/index.html b/index.html
index 4b77815..b6ff1ed 100644
--- a/index.html
+++ b/index.html
@@ -19,9 +19,10 @@
diff --git a/src/js/app.js b/src/js/app.js
index c6d41ff..d8e17de 100644
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -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', () => {
diff --git a/src/js/snippet-manager.js b/src/js/snippet-manager.js
index 4cc0ac0..d8dabd8 100644
--- a/src/js/snippet-manager.js
+++ b/src/js/snippet-manager.js
@@ -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 ? `${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';
@@ -249,6 +254,7 @@ function renderSnippetList(searchQuery = null) {
${snippet.name}
${dateText}
+ ${sizeHTML}
`;
@@ -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);
+}
+
diff --git a/src/styles.css b/src/styles.css
index 7eaced8..3b6d33d 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -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;