feat: enhance snippet management with import/export functionality and size display

This commit is contained in:
2025-10-13 16:00:34 +03:00
parent eaf14aafdd
commit 1734001d20
6 changed files with 188 additions and 20 deletions

View File

@@ -15,7 +15,7 @@ Instructions for Claude Code when working on this project.
## Current Status ## Current Status
**Completed**: Phases 0-8 (All core functionality including storage monitoring) **Completed**: Phases 0-9 (All core functionality including import/export)
**Next**: Phase 9 - Export/Import **Next**: Phase 10 - Dataset Management
See `docs/dev-plan.md` for complete roadmap and technical details. See `docs/dev-plan.md` for complete roadmap and technical details.

View File

@@ -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 **Goal**: Portability and backup
- [ ] Export single snippet as JSON file (include metadata) **Deliverables**:
- [ ] Export all snippets as JSON bundle - Export all snippets to JSON file with auto-generated filename (astrolabe-snippets-YYYY-MM-DD.json)
- [ ] Import snippets from JSON (with conflict resolution) - Import snippets from JSON with automatic format detection
- [ ] Drag-and-drop import - Support for both Astrolabe native format and external formats
- [ ] Export published vs draft options - 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 ## Current Status
**Completed**: Phases 0-8 (Storage, UI, editor, rendering, persistence, CRUD, organization, draft/published workflow, storage monitoring) **Completed**: Phases 0-9 (Storage, UI, editor, rendering, persistence, CRUD, organization, draft/published workflow, storage monitoring, import/export)
**Next**: Phase 9 - Export/Import **Next**: Phase 10 - Dataset Management
**See**: `CLAUDE.md` for concise current state summary **See**: `CLAUDE.md` for concise current state summary
--- ---
## Implemented Features ## Implemented Features
### Core Capabilities (Phases 0-8) ### Core Capabilities (Phases 0-9)
- Three-panel resizable layout with memory and persistence - Three-panel resizable layout with memory and persistence
- Monaco Editor v0.47.0 with Vega-Lite v5 schema validation - Monaco Editor v0.47.0 with Vega-Lite v5 schema validation
- Live Vega-Lite rendering with debounced updates and error display - 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 - Status indicator lights (green/yellow) showing draft state
- Context-aware Publish/Revert buttons with color coding - Context-aware Publish/Revert buttons with color coding
- Storage monitoring with visual progress bar and warning states - 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 - Retro Windows 2000 aesthetic throughout
### Technical Implementation ### 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 - **Panel Memory**: localStorage persistence for sizes and visibility across sessions
- **Data Model**: Phase 0 schema with `spec` (published) and `draftSpec` (working) fields - **Data Model**: Phase 0 schema with `spec` (published) and `draftSpec` (working) fields
- **Storage Calculation**: Blob API for accurate byte counting of snippet data - **Storage Calculation**: Blob API for accurate byte counting of snippet data
- **Flexbox Layout**: Scrollable snippet list with fixed metadata and storage monitor at bottom - **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)

View File

@@ -19,9 +19,10 @@
<span class="header-title">Astrolabe</span> <span class="header-title">Astrolabe</span>
</div> </div>
<div class="header-links"> <div class="header-links">
<span class="header-link">Import</span> <span class="header-link" id="import-link">Import</span>
<span class="header-link">Export</span> <span class="header-link" id="export-link">Export</span>
<span class="header-link">Help</span> <span class="header-link" id="help-link">Help</span>
<input type="file" id="import-file-input" accept=".json" style="display: none;" />
</div> </div>
</div> </div>

View File

@@ -89,12 +89,33 @@ document.addEventListener('DOMContentLoaded', function () {
}); });
}); });
// Header links - show placeholder // Header links
document.querySelectorAll('.header-link').forEach(link => { const importLink = document.getElementById('import-link');
link.addEventListener('click', function () { 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!'); alert('Coming soon in a future phase!');
}); });
}); }
// View mode toggle buttons // View mode toggle buttons
document.getElementById('view-draft').addEventListener('click', () => { document.getElementById('view-draft').addEventListener('click', () => {

View File

@@ -239,6 +239,11 @@ function renderSnippetList(searchQuery = null) {
dateText = formatSnippetDate(snippet.modified); 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 // Determine status: green if no draft changes, yellow if has draft
const hasDraft = JSON.stringify(snippet.spec) !== JSON.stringify(snippet.draftSpec); const hasDraft = JSON.stringify(snippet.spec) !== JSON.stringify(snippet.draftSpec);
const statusClass = hasDraft ? 'draft' : 'published'; const statusClass = hasDraft ? 'draft' : 'published';
@@ -249,6 +254,7 @@ function renderSnippetList(searchQuery = null) {
<div class="snippet-name">${snippet.name}</div> <div class="snippet-name">${snippet.name}</div>
<div class="snippet-date">${dateText}</div> <div class="snippet-date">${dateText}</div>
</div> </div>
${sizeHTML}
<div class="snippet-status ${statusClass}"></div> <div class="snippet-status ${statusClass}"></div>
</li> </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);
}

View File

@@ -458,6 +458,14 @@ body {
margin-top: 1px; margin-top: 1px;
} }
.snippet-size {
font-size: 10px;
color: #808080;
margin-left: auto;
margin-right: 8px;
flex-shrink: 0;
}
.editor-placeholder { .editor-placeholder {
background: #ffffff; background: #ffffff;
border: 2px inset #c0c0c0; border: 2px inset #c0c0c0;