feat: improve Obsidian vault storage with IndexedDB content store
- Add noteContentStore.ts for storing full note content in IndexedDB - Avoids Automerge WASM capacity limits and localStorage quota (~5MB) - Only metadata (id, title, tags, links) syncs via Automerge - Full content stays local and loads on-demand - Handle ephemeral messages in AutomergeDurableObject for cursor sync - Improvements to ObsidianVaultBrowser component - Enhanced obsidianImporter functionality Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2030ae447d
commit
b2941333f3
|
|
@ -1,8 +1,9 @@
|
|||
import React, { useState, useEffect, useMemo, useContext, useRef } from 'react'
|
||||
import { ObsidianImporter, ObsidianObsNote, ObsidianVault, FolderNode, ObsidianVaultRecord } from '@/lib/obsidianImporter'
|
||||
import React, { useState, useEffect, useMemo, useContext, useRef, useCallback } from 'react'
|
||||
import { ObsidianImporter, ObsidianObsNote, ObsidianVault, FolderNode, ObsidianVaultRecord, ObsidianVaultRecordLight, ObsidianObsNoteMeta, FolderNodeMeta } from '@/lib/obsidianImporter'
|
||||
import { AuthContext } from '@/context/AuthContext'
|
||||
import { useEditor } from '@tldraw/tldraw'
|
||||
import { useAutomergeHandle } from '@/context/AutomergeHandleContext'
|
||||
import { saveVaultNoteContents, getVaultNoteContents, getNoteContent, deleteVaultNoteContents } from '@/lib/noteContentStore'
|
||||
|
||||
interface ObsidianVaultBrowserProps {
|
||||
onObsNoteSelect: (obs_note: ObsidianObsNote) => void
|
||||
|
|
@ -79,45 +80,57 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
}
|
||||
}, [vault])
|
||||
|
||||
// Save vault to Automerge store
|
||||
const saveVaultToAutomerge = (vault: ObsidianVault) => {
|
||||
// Save vault to Automerge store (metadata only) and IndexedDB (content)
|
||||
const saveVaultToAutomerge = async (vault: ObsidianVault) => {
|
||||
// First, save full note content to IndexedDB (local-only, never synced)
|
||||
try {
|
||||
const noteContents = importer.extractNoteContents(vault)
|
||||
await saveVaultNoteContents(vault.name, noteContents)
|
||||
console.log(`Saved ${noteContents.length} note contents to IndexedDB for vault: ${vault.name}`)
|
||||
} catch (idbError) {
|
||||
console.error('Error saving note contents to IndexedDB:', idbError)
|
||||
}
|
||||
|
||||
// Create light record (no content) for Automerge sync
|
||||
const lightRecord = importer.vaultToLightRecord(vault)
|
||||
|
||||
if (!automergeHandle) {
|
||||
// No Automerge, just save light metadata to localStorage
|
||||
try {
|
||||
const vaultRecord = importer.vaultToRecord(vault)
|
||||
localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({
|
||||
...vaultRecord,
|
||||
lastImported: vaultRecord.lastImported instanceof Date ? vaultRecord.lastImported.toISOString() : vaultRecord.lastImported
|
||||
...lightRecord,
|
||||
lastImported: lightRecord.lastImported instanceof Date ? lightRecord.lastImported.toISOString() : lightRecord.lastImported
|
||||
}))
|
||||
} catch (localStorageError) {
|
||||
console.warn('Could not save vault to localStorage:', localStorageError)
|
||||
console.warn('Could not save vault metadata to localStorage:', localStorageError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const vaultRecord = importer.vaultToRecord(vault)
|
||||
|
||||
// Save directly to Automerge, bypassing TLDraw store validation
|
||||
// Save LIGHT record (no content) to Automerge - this is safe and won't overflow
|
||||
automergeHandle.change((doc: any) => {
|
||||
if (!doc.store) {
|
||||
doc.store = {}
|
||||
}
|
||||
|
||||
const recordToSave = {
|
||||
...vaultRecord,
|
||||
lastImported: vaultRecord.lastImported instanceof Date
|
||||
? vaultRecord.lastImported.toISOString()
|
||||
: vaultRecord.lastImported
|
||||
...lightRecord,
|
||||
lastImported: lightRecord.lastImported instanceof Date
|
||||
? lightRecord.lastImported.toISOString()
|
||||
: lightRecord.lastImported
|
||||
}
|
||||
|
||||
doc.store[vaultRecord.id] = recordToSave
|
||||
doc.store[lightRecord.id] = recordToSave
|
||||
})
|
||||
|
||||
// Also save to localStorage as a backup
|
||||
console.log(`Saved light vault record to Automerge: ${lightRecord.id} (${lightRecord.totalObsNotes} notes, content stored locally)`)
|
||||
|
||||
// Also save light metadata to localStorage as backup
|
||||
try {
|
||||
localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({
|
||||
...vaultRecord,
|
||||
lastImported: vaultRecord.lastImported instanceof Date ? vaultRecord.lastImported.toISOString() : vaultRecord.lastImported
|
||||
...lightRecord,
|
||||
lastImported: lightRecord.lastImported instanceof Date ? lightRecord.lastImported.toISOString() : lightRecord.lastImported
|
||||
}))
|
||||
} catch (localStorageError) {
|
||||
// Silent fail for backup
|
||||
|
|
@ -126,10 +139,9 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
console.error('Error saving vault to Automerge:', error)
|
||||
// Try localStorage as fallback
|
||||
try {
|
||||
const vaultRecord = importer.vaultToRecord(vault)
|
||||
localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({
|
||||
...vaultRecord,
|
||||
lastImported: vaultRecord.lastImported instanceof Date ? vaultRecord.lastImported.toISOString() : vaultRecord.lastImported
|
||||
...lightRecord,
|
||||
lastImported: lightRecord.lastImported instanceof Date ? lightRecord.lastImported.toISOString() : lightRecord.lastImported
|
||||
}))
|
||||
} catch (localStorageError) {
|
||||
console.warn('Could not save vault to localStorage:', localStorageError)
|
||||
|
|
@ -137,7 +149,87 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// Load vault from Automerge store
|
||||
// Clear vault data from Automerge (for privacy - removes any synced vault data)
|
||||
const clearVaultFromAutomerge = useCallback((vaultName: string) => {
|
||||
const vaultId = `obsidian_vault:${vaultName}`
|
||||
|
||||
if (automergeHandle) {
|
||||
try {
|
||||
automergeHandle.change((doc: any) => {
|
||||
if (doc.store && doc.store[vaultId]) {
|
||||
delete doc.store[vaultId]
|
||||
console.log(`Cleared vault from Automerge: ${vaultId}`)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error clearing vault from Automerge:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Also clear from localStorage
|
||||
try {
|
||||
localStorage.removeItem(`obsidian_vault_cache:${vaultName}`)
|
||||
console.log(`Cleared vault from localStorage: ${vaultName}`)
|
||||
} catch (e) {
|
||||
// Silent fail
|
||||
}
|
||||
|
||||
// Clear from IndexedDB
|
||||
deleteVaultNoteContents(vaultName).then(() => {
|
||||
console.log(`Cleared vault content from IndexedDB: ${vaultName}`)
|
||||
}).catch(e => {
|
||||
console.error('Error clearing vault from IndexedDB:', e)
|
||||
})
|
||||
}, [automergeHandle])
|
||||
|
||||
// Helper to check if a note has content (distinguishes light vs full records)
|
||||
const noteHasContent = (note: ObsidianObsNote | ObsidianObsNoteMeta): note is ObsidianObsNote => {
|
||||
return 'content' in note && typeof note.content === 'string' && note.content.length > 0
|
||||
}
|
||||
|
||||
// Convert light note to full note with placeholder content
|
||||
const lightNoteToFullNote = (note: ObsidianObsNoteMeta, vaultName: string): ObsidianObsNote => {
|
||||
return {
|
||||
...note,
|
||||
content: '', // Will be loaded on-demand from IndexedDB
|
||||
vaultPath: note.vaultPath || vaultName
|
||||
}
|
||||
}
|
||||
|
||||
// Convert light folder tree to full folder tree (with empty content placeholders)
|
||||
const lightFolderTreeToFull = (node: FolderNodeMeta, vaultName: string): FolderNode => {
|
||||
return {
|
||||
name: node.name,
|
||||
path: node.path,
|
||||
children: node.children.map(child => lightFolderTreeToFull(child, vaultName)),
|
||||
notes: node.notes.map(note => lightNoteToFullNote(note, vaultName)),
|
||||
isExpanded: node.isExpanded,
|
||||
level: node.level
|
||||
}
|
||||
}
|
||||
|
||||
// Load content for a note from IndexedDB
|
||||
const loadNoteContentFromIDB = async (noteId: string): Promise<string> => {
|
||||
try {
|
||||
const content = await getNoteContent(noteId)
|
||||
return content || ''
|
||||
} catch (e) {
|
||||
console.error('Failed to load note content from IndexedDB:', e)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Load content for multiple notes from IndexedDB
|
||||
const loadVaultContentFromIDB = async (vaultName: string): Promise<Map<string, string>> => {
|
||||
try {
|
||||
return await getVaultNoteContents(vaultName)
|
||||
} catch (e) {
|
||||
console.error('Failed to load vault content from IndexedDB:', e)
|
||||
return new Map()
|
||||
}
|
||||
}
|
||||
|
||||
// Load vault from Automerge store (handles both light and full records)
|
||||
const loadVaultFromAutomerge = (vaultName: string): ObsidianVault | null => {
|
||||
// Try loading from Automerge first
|
||||
if (automergeHandle) {
|
||||
|
|
@ -145,17 +237,34 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
const doc = automergeHandle.doc()
|
||||
if (doc && doc.store) {
|
||||
const vaultId = `obsidian_vault:${vaultName}`
|
||||
const vaultRecord = doc.store[vaultId] as ObsidianVaultRecord | undefined
|
||||
const vaultRecord = doc.store[vaultId] as (ObsidianVaultRecord | ObsidianVaultRecordLight) | undefined
|
||||
|
||||
if (vaultRecord && vaultRecord.typeName === 'obsidian_vault') {
|
||||
const recordCopy = JSON.parse(JSON.stringify(vaultRecord))
|
||||
if (typeof recordCopy.lastImported === 'string') {
|
||||
recordCopy.lastImported = new Date(recordCopy.lastImported)
|
||||
}
|
||||
|
||||
// Check if this is a light record (notes don't have content)
|
||||
const isLightRecord = recordCopy.obs_notes.length > 0 && !noteHasContent(recordCopy.obs_notes[0])
|
||||
|
||||
if (isLightRecord) {
|
||||
// Convert light record to full vault with empty content placeholders
|
||||
return {
|
||||
name: recordCopy.name,
|
||||
path: recordCopy.path,
|
||||
obs_notes: recordCopy.obs_notes.map((n: ObsidianObsNoteMeta) => lightNoteToFullNote(n, vaultName)),
|
||||
totalObsNotes: recordCopy.totalObsNotes,
|
||||
lastImported: recordCopy.lastImported,
|
||||
folderTree: lightFolderTreeToFull(recordCopy.folderTree, vaultName)
|
||||
}
|
||||
}
|
||||
|
||||
return importer.recordToVault(recordCopy)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading from Automerge:', error)
|
||||
// Fall through to localStorage
|
||||
}
|
||||
}
|
||||
|
|
@ -164,12 +273,27 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
try {
|
||||
const cached = localStorage.getItem(`obsidian_vault_cache:${vaultName}`)
|
||||
if (cached) {
|
||||
const vaultRecord = JSON.parse(cached) as ObsidianVaultRecord
|
||||
const vaultRecord = JSON.parse(cached) as (ObsidianVaultRecord | ObsidianVaultRecordLight)
|
||||
if (vaultRecord && vaultRecord.typeName === 'obsidian_vault') {
|
||||
if (typeof vaultRecord.lastImported === 'string') {
|
||||
vaultRecord.lastImported = new Date(vaultRecord.lastImported)
|
||||
}
|
||||
return importer.recordToVault(vaultRecord)
|
||||
|
||||
// Check if this is a light record
|
||||
const isLightRecord = vaultRecord.obs_notes.length > 0 && !noteHasContent(vaultRecord.obs_notes[0])
|
||||
|
||||
if (isLightRecord) {
|
||||
return {
|
||||
name: vaultRecord.name,
|
||||
path: vaultRecord.path,
|
||||
obs_notes: vaultRecord.obs_notes.map((n: any) => lightNoteToFullNote(n, vaultName)),
|
||||
totalObsNotes: vaultRecord.totalObsNotes,
|
||||
lastImported: vaultRecord.lastImported,
|
||||
folderTree: lightFolderTreeToFull(vaultRecord.folderTree as FolderNodeMeta, vaultName)
|
||||
}
|
||||
}
|
||||
|
||||
return importer.recordToVault(vaultRecord as ObsidianVaultRecord)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -564,8 +688,14 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
const handleObsNoteClick = (obs_note: ObsidianObsNote) => {
|
||||
onObsNoteSelect(obs_note)
|
||||
const handleObsNoteClick = async (obs_note: ObsidianObsNote) => {
|
||||
// Load content from IndexedDB if not already loaded
|
||||
if (!obs_note.content && vault) {
|
||||
const content = await loadNoteContentFromIDB(obs_note.id)
|
||||
onObsNoteSelect({ ...obs_note, content })
|
||||
} else {
|
||||
onObsNoteSelect(obs_note)
|
||||
}
|
||||
}
|
||||
|
||||
const handleObsNoteToggle = (obs_note: ObsidianObsNote) => {
|
||||
|
|
@ -578,9 +708,21 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
setSelectedNotes(newSelected)
|
||||
}
|
||||
|
||||
const handleBulkImport = () => {
|
||||
const handleBulkImport = async () => {
|
||||
const selectedObsNotes = filteredObsNotes.filter(obs_note => selectedNotes.has(obs_note.id))
|
||||
onObsNotesSelect(selectedObsNotes)
|
||||
|
||||
// Load content from IndexedDB for all selected notes
|
||||
if (vault) {
|
||||
const contentMap = await loadVaultContentFromIDB(vault.name)
|
||||
const notesWithContent = selectedObsNotes.map(note => ({
|
||||
...note,
|
||||
content: note.content || contentMap.get(note.id) || ''
|
||||
}))
|
||||
onObsNotesSelect(notesWithContent)
|
||||
} else {
|
||||
onObsNotesSelect(selectedObsNotes)
|
||||
}
|
||||
|
||||
setSelectedNotes(new Set())
|
||||
}
|
||||
|
||||
|
|
@ -630,6 +772,11 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
|
||||
|
||||
const handleDisconnectVault = () => {
|
||||
// Clear vault from Automerge/sync when disconnecting
|
||||
if (vault) {
|
||||
clearVaultFromAutomerge(vault.name)
|
||||
}
|
||||
|
||||
updateSession({
|
||||
obsidianVaultPath: undefined,
|
||||
obsidianVaultName: undefined
|
||||
|
|
@ -646,6 +793,14 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
setIsLoadingVault(false)
|
||||
}
|
||||
|
||||
// Clear vault data from Automerge without disconnecting (for privacy)
|
||||
const handleClearVaultFromSync = () => {
|
||||
if (vault) {
|
||||
clearVaultFromAutomerge(vault.name)
|
||||
alert(`Cleared "${vault.name}" from sync. Your vault data will no longer be visible to others.`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Only close if clicking on the backdrop, not on the modal content
|
||||
if (e.target === e.currentTarget) {
|
||||
|
|
@ -1119,8 +1274,16 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
>
|
||||
🔌 Disconnect Vault
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearVaultFromSync}
|
||||
className="clear-sync-button"
|
||||
title="Clear vault data from sync (keeps local data)"
|
||||
style={{ marginLeft: '8px', fontSize: '0.85em', opacity: 0.8 }}
|
||||
>
|
||||
🗑️ Clear from Sync
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="selection-controls">
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
|
|
@ -1374,8 +1537,16 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
>
|
||||
🔌 Disconnect Vault
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearVaultFromSync}
|
||||
className="clear-sync-button"
|
||||
title="Clear vault data from sync (keeps local data)"
|
||||
style={{ marginLeft: '8px', fontSize: '0.85em', opacity: 0.8 }}
|
||||
>
|
||||
🗑️ Clear from Sync
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="selection-controls">
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,283 @@
|
|||
/**
|
||||
* IndexedDB store for Obsidian note content
|
||||
*
|
||||
* Stores full note content locally in IndexedDB to avoid:
|
||||
* 1. Automerge capacity overflow (WASM limits)
|
||||
* 2. localStorage quota exceeded (~5MB limit)
|
||||
* 3. Syncing private note content to other users
|
||||
*
|
||||
* Only metadata (id, title, tags, links) is synced via Automerge.
|
||||
* Full content stays local and is loaded on-demand.
|
||||
*/
|
||||
|
||||
const DB_NAME = 'canvas-obsidian-content';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'note_content';
|
||||
const VAULT_META_STORE = 'vault_metadata';
|
||||
|
||||
let dbInstance: IDBDatabase | null = null;
|
||||
|
||||
export interface StoredNoteContent {
|
||||
id: string; // Note ID (matches ObsidianObsNote.id)
|
||||
vaultName: string; // Vault this note belongs to
|
||||
content: string; // Full markdown content
|
||||
storedAt: Date; // When this was stored
|
||||
}
|
||||
|
||||
export interface StoredVaultMeta {
|
||||
vaultName: string;
|
||||
noteCount: number;
|
||||
totalSize: number; // Approximate size in bytes
|
||||
storedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open or create the IndexedDB database
|
||||
*/
|
||||
export async function openNoteContentDB(): Promise<IDBDatabase> {
|
||||
if (dbInstance) {
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('Failed to open note content database:', request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
dbInstance = request.result;
|
||||
|
||||
// Handle connection closing unexpectedly
|
||||
dbInstance.onclose = () => {
|
||||
dbInstance = null;
|
||||
};
|
||||
|
||||
resolve(dbInstance);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
// Note content store - keyed by note ID
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
store.createIndex('vaultName', 'vaultName', { unique: false });
|
||||
store.createIndex('storedAt', 'storedAt', { unique: false });
|
||||
}
|
||||
|
||||
// Vault metadata store
|
||||
if (!db.objectStoreNames.contains(VAULT_META_STORE)) {
|
||||
db.createObjectStore(VAULT_META_STORE, { keyPath: 'vaultName' });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
export function closeNoteContentDB(): void {
|
||||
if (dbInstance) {
|
||||
dbInstance.close();
|
||||
dbInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a single note's content to IndexedDB
|
||||
*/
|
||||
export async function saveNoteContent(
|
||||
noteId: string,
|
||||
vaultName: string,
|
||||
content: string
|
||||
): Promise<void> {
|
||||
const db = await openNoteContentDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
|
||||
const record: StoredNoteContent = {
|
||||
id: noteId,
|
||||
vaultName,
|
||||
content,
|
||||
storedAt: new Date()
|
||||
};
|
||||
|
||||
const request = store.put(record);
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save all note contents for a vault in a batch operation
|
||||
*/
|
||||
export async function saveVaultNoteContents(
|
||||
vaultName: string,
|
||||
notes: Array<{ id: string; content: string }>
|
||||
): Promise<void> {
|
||||
const db = await openNoteContentDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction([STORE_NAME, VAULT_META_STORE], 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const metaStore = tx.objectStore(VAULT_META_STORE);
|
||||
|
||||
let totalSize = 0;
|
||||
const now = new Date();
|
||||
|
||||
// Save each note's content
|
||||
for (const note of notes) {
|
||||
const record: StoredNoteContent = {
|
||||
id: note.id,
|
||||
vaultName,
|
||||
content: note.content,
|
||||
storedAt: now
|
||||
};
|
||||
store.put(record);
|
||||
totalSize += note.content.length;
|
||||
}
|
||||
|
||||
// Save vault metadata
|
||||
const meta: StoredVaultMeta = {
|
||||
vaultName,
|
||||
noteCount: notes.length,
|
||||
totalSize,
|
||||
storedAt: now
|
||||
};
|
||||
metaStore.put(meta);
|
||||
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single note's content from IndexedDB
|
||||
*/
|
||||
export async function getNoteContent(noteId: string): Promise<string | null> {
|
||||
const db = await openNoteContentDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.get(noteId);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const record = request.result as StoredNoteContent | undefined;
|
||||
resolve(record?.content ?? null);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all note contents for a vault
|
||||
*/
|
||||
export async function getVaultNoteContents(
|
||||
vaultName: string
|
||||
): Promise<Map<string, string>> {
|
||||
const db = await openNoteContentDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const index = store.index('vaultName');
|
||||
const request = index.getAll(vaultName);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const records = request.result as StoredNoteContent[];
|
||||
const contentMap = new Map<string, string>();
|
||||
for (const record of records) {
|
||||
contentMap.set(record.id, record.content);
|
||||
}
|
||||
resolve(contentMap);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all note contents for a vault
|
||||
*/
|
||||
export async function deleteVaultNoteContents(vaultName: string): Promise<void> {
|
||||
const db = await openNoteContentDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction([STORE_NAME, VAULT_META_STORE], 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const metaStore = tx.objectStore(VAULT_META_STORE);
|
||||
const index = store.index('vaultName');
|
||||
|
||||
// Get all note IDs for this vault
|
||||
const request = index.getAllKeys(vaultName);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const keys = request.result;
|
||||
for (const key of keys) {
|
||||
store.delete(key);
|
||||
}
|
||||
metaStore.delete(vaultName);
|
||||
};
|
||||
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vault metadata (note count, size)
|
||||
*/
|
||||
export async function getVaultMeta(vaultName: string): Promise<StoredVaultMeta | null> {
|
||||
const db = await openNoteContentDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(VAULT_META_STORE, 'readonly');
|
||||
const store = tx.objectStore(VAULT_META_STORE);
|
||||
const request = store.get(vaultName);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result as StoredVaultMeta | null);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List all vaults stored in IndexedDB
|
||||
*/
|
||||
export async function listStoredVaults(): Promise<StoredVaultMeta[]> {
|
||||
const db = await openNoteContentDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(VAULT_META_STORE, 'readonly');
|
||||
const store = tx.objectStore(VAULT_META_STORE);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result as StoredVaultMeta[]);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stored content (for debugging/reset)
|
||||
*/
|
||||
export async function clearAllNoteContent(): Promise<void> {
|
||||
const db = await openNoteContentDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction([STORE_NAME, VAULT_META_STORE], 'readwrite');
|
||||
tx.objectStore(STORE_NAME).clear();
|
||||
tx.objectStore(VAULT_META_STORE).clear();
|
||||
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
|
@ -50,6 +50,52 @@ export interface ObsidianVaultRecord {
|
|||
meta: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Light version of note without content - for Automerge sync
|
||||
* Full content is stored in IndexedDB to avoid capacity overflow
|
||||
*/
|
||||
export interface ObsidianObsNoteMeta {
|
||||
id: string
|
||||
title: string
|
||||
filePath: string
|
||||
tags: string[]
|
||||
created: Date | string
|
||||
modified: Date | string
|
||||
links: string[]
|
||||
backlinks: string[]
|
||||
frontmatter: Record<string, any>
|
||||
vaultPath?: string
|
||||
// content is omitted - stored in IndexedDB
|
||||
}
|
||||
|
||||
/**
|
||||
* Light version of FolderNode without full note content
|
||||
*/
|
||||
export interface FolderNodeMeta {
|
||||
name: string
|
||||
path: string
|
||||
children: FolderNodeMeta[]
|
||||
notes: ObsidianObsNoteMeta[] // Notes without content
|
||||
isExpanded: boolean
|
||||
level: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Light vault record for Automerge sync - no note content
|
||||
* This prevents capacity overflow and keeps private content local
|
||||
*/
|
||||
export interface ObsidianVaultRecordLight {
|
||||
id: string
|
||||
typeName: 'obsidian_vault'
|
||||
name: string
|
||||
path: string
|
||||
obs_notes: ObsidianObsNoteMeta[] // Notes without content
|
||||
totalObsNotes: number
|
||||
lastImported: Date
|
||||
folderTree: FolderNodeMeta // Folder tree with light notes
|
||||
meta: Record<string, any>
|
||||
}
|
||||
|
||||
export class ObsidianImporter {
|
||||
private vault: ObsidianVault | null = null
|
||||
|
||||
|
|
@ -627,6 +673,67 @@ A collection of creative project ideas and concepts.
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip content from a note, keeping only metadata
|
||||
*/
|
||||
noteToMeta(note: ObsidianObsNote): ObsidianObsNoteMeta {
|
||||
return {
|
||||
id: note.id,
|
||||
title: note.title,
|
||||
filePath: note.filePath,
|
||||
tags: note.tags,
|
||||
created: note.created,
|
||||
modified: note.modified,
|
||||
links: note.links,
|
||||
backlinks: note.backlinks,
|
||||
frontmatter: note.frontmatter,
|
||||
vaultPath: note.vaultPath
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert folder tree to light version (notes without content)
|
||||
*/
|
||||
folderTreeToMeta(node: FolderNode): FolderNodeMeta {
|
||||
return {
|
||||
name: node.name,
|
||||
path: node.path,
|
||||
children: node.children.map(child => this.folderTreeToMeta(child)),
|
||||
notes: node.notes.map(note => this.noteToMeta(note)),
|
||||
isExpanded: node.isExpanded,
|
||||
level: node.level
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert vault to light Automerge record format (no note content)
|
||||
* Use this for syncing to Automerge to avoid capacity overflow
|
||||
*/
|
||||
vaultToLightRecord(vault: ObsidianVault): ObsidianVaultRecordLight {
|
||||
return {
|
||||
id: `obsidian_vault:${vault.name}`,
|
||||
typeName: 'obsidian_vault',
|
||||
name: vault.name,
|
||||
path: vault.path,
|
||||
obs_notes: vault.obs_notes.map(note => this.noteToMeta(note)),
|
||||
totalObsNotes: vault.totalObsNotes,
|
||||
lastImported: vault.lastImported,
|
||||
folderTree: this.folderTreeToMeta(vault.folderTree),
|
||||
meta: {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract note contents for IndexedDB storage
|
||||
* Returns array of {id, content} for batch saving
|
||||
*/
|
||||
extractNoteContents(vault: ObsidianVault): Array<{ id: string; content: string }> {
|
||||
return vault.obs_notes.map(note => ({
|
||||
id: note.id,
|
||||
content: note.content
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Search notes in the current vault
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -639,6 +639,14 @@ export class AutomergeDurableObject {
|
|||
sessionId: message.sessionId || sessionId
|
||||
})
|
||||
break
|
||||
case "ephemeral":
|
||||
// Handle Automerge's ephemeral messages (temporary data like cursor positions)
|
||||
// These are part of automerge-repo's sync protocol and should be relayed to other peers
|
||||
// but NOT persisted. They're used for real-time presence/cursor sharing.
|
||||
console.log(`✨ Received ephemeral message from ${sessionId}`)
|
||||
// Relay the ephemeral message to all other connected clients
|
||||
this.broadcastToOthers(sessionId, message)
|
||||
break
|
||||
default:
|
||||
console.log("Unknown message type:", message.type)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue