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
Related\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)
═══════════════════════════════════════════════════════ */