diff --git a/app.js b/app.js index 21c5c222..12d67880 100644 --- a/app.js +++ b/app.js @@ -2006,6 +2006,30 @@ function setupControls() { document.getElementById('atlas-toggle-btn').addEventListener('click', openPortalAtlas); document.getElementById('atlas-close-btn').addEventListener('click', closePortalAtlas); + + // Mnemosyne export/import (#1174) + document.getElementById('mnemosyne-export-btn').addEventListener('click', () => { + const result = SpatialMemory.exportToFile(); + if (result) { + addChatMessage('system', 'Mnemosyne: Exported ' + result.count + ' memories to ' + result.filename); + } + }); + + document.getElementById('mnemosyne-import-btn').addEventListener('click', () => { + document.getElementById('mnemosyne-import-file').click(); + }); + + document.getElementById('mnemosyne-import-file').addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + try { + const result = await SpatialMemory.importFromFile(file); + addChatMessage('system', 'Mnemosyne: Imported ' + result.count + ' of ' + result.total + ' memories'); + } catch (err) { + addChatMessage('system', 'Mnemosyne: Import failed — ' + err.message); + } + e.target.value = ''; + }); } function sendChatMessage(overrideText = null) { diff --git a/index.html b/index.html index 54ada489..98b6fe23 100644 --- a/index.html +++ b/index.html @@ -233,6 +233,11 @@
Time\u2014
+
+ + + +
diff --git a/nexus/components/spatial-memory.js b/nexus/components/spatial-memory.js index ab3fdd87..87e3e770 100644 --- a/nexus/components/spatial-memory.js +++ b/nexus/components/spatial-memory.js @@ -652,11 +652,93 @@ const SpatialMemory = (() => { return _selectedId; } + // ─── FILE EXPORT ────────────────────────────────────── + function exportToFile() { + const index = exportIndex(); + const json = JSON.stringify(index, null, 2); + const date = new Date().toISOString().slice(0, 10); + const filename = 'mnemosyne-export-' + date + '.json'; + + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + console.info('[Mnemosyne] Exported', index.memories.length, 'memories to', filename); + return { filename, count: index.memories.length }; + } + + // ─── FILE IMPORT ────────────────────────────────────── + function importFromFile(file) { + return new Promise((resolve, reject) => { + if (!file) { + reject(new Error('No file provided')); + return; + } + + const reader = new FileReader(); + reader.onload = function(e) { + try { + const data = JSON.parse(e.target.result); + + // Schema validation + if (!data || typeof data !== 'object') { + reject(new Error('Invalid JSON: not an object')); + return; + } + if (typeof data.version !== 'number') { + reject(new Error('Invalid schema: missing version field')); + return; + } + if (data.version !== STORAGE_VERSION) { + reject(new Error('Version mismatch: got ' + data.version + ', expected ' + STORAGE_VERSION)); + return; + } + if (!Array.isArray(data.memories)) { + reject(new Error('Invalid schema: memories is not an array')); + return; + } + + // Validate each memory entry + for (let i = 0; i < data.memories.length; i++) { + const mem = data.memories[i]; + if (!mem.id || typeof mem.id !== 'string') { + reject(new Error('Invalid memory at index ' + i + ': missing or invalid id')); + return; + } + if (!mem.category || typeof mem.category !== 'string') { + reject(new Error('Invalid memory "' + mem.id + '": missing category')); + return; + } + } + + const count = importIndex(data); + saveToStorage(); + console.info('[Mnemosyne] Imported', count, 'memories from file'); + resolve({ count, total: data.memories.length }); + } catch (parseErr) { + reject(new Error('Failed to parse JSON: ' + parseErr.message)); + } + }; + + reader.onerror = function() { + reject(new Error('Failed to read file')); + }; + + reader.readAsText(file); + }); + } + return { init, placeMemory, removeMemory, update, getMemoryAtPosition, getRegionAtPosition, getMemoriesInRegion, getAllMemories, getCrystalMeshes, getMemoryFromMesh, highlightMemory, clearHighlight, getSelectedId, - exportIndex, importIndex, searchNearby, REGIONS, + exportIndex, importIndex, exportToFile, importFromFile, searchNearby, REGIONS, saveToStorage, loadFromStorage, clearStorage, runGravityLayout }; diff --git a/style.css b/style.css index 2c3f70eb..ca208caa 100644 --- a/style.css +++ b/style.css @@ -1461,6 +1461,43 @@ canvas#nexus-canvas { gap: 2px; } +/* ═══════════════════════════════════════════════════════ + PROJECT MNEMOSYNE — EXPORT/IMPORT ACTIONS (#1174) +═══════════════════════════════════════════════════════ */ + +.memory-panel-actions { + display: flex; + gap: 8px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(123, 92, 255, 0.15); +} + +.mnemosyne-action-btn { + flex: 1; + padding: 6px 10px; + background: rgba(123, 92, 255, 0.12); + border: 1px solid rgba(123, 92, 255, 0.3); + border-radius: 6px; + color: #a08cff; + font-size: 11px; + font-family: monospace; + cursor: pointer; + transition: all 0.15s ease; + text-align: center; + white-space: nowrap; +} + +.mnemosyne-action-btn:hover { + background: rgba(123, 92, 255, 0.25); + border-color: rgba(123, 92, 255, 0.6); + color: #c4b5ff; +} + +.mnemosyne-action-btn:active { + transform: scale(0.96); +} + /* ═══════════════════════════════════════════════════════ PROJECT MNEMOSYNE — SESSION ROOM HUD PANEL (#1171) ═══════════════════════════════════════════════════════ */