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 React, { useState, useEffect, useMemo, useContext, useRef, useCallback } from 'react'
|
||||||
import { ObsidianImporter, ObsidianObsNote, ObsidianVault, FolderNode, ObsidianVaultRecord } from '@/lib/obsidianImporter'
|
import { ObsidianImporter, ObsidianObsNote, ObsidianVault, FolderNode, ObsidianVaultRecord, ObsidianVaultRecordLight, ObsidianObsNoteMeta, FolderNodeMeta } from '@/lib/obsidianImporter'
|
||||||
import { AuthContext } from '@/context/AuthContext'
|
import { AuthContext } from '@/context/AuthContext'
|
||||||
import { useEditor } from '@tldraw/tldraw'
|
import { useEditor } from '@tldraw/tldraw'
|
||||||
import { useAutomergeHandle } from '@/context/AutomergeHandleContext'
|
import { useAutomergeHandle } from '@/context/AutomergeHandleContext'
|
||||||
|
import { saveVaultNoteContents, getVaultNoteContents, getNoteContent, deleteVaultNoteContents } from '@/lib/noteContentStore'
|
||||||
|
|
||||||
interface ObsidianVaultBrowserProps {
|
interface ObsidianVaultBrowserProps {
|
||||||
onObsNoteSelect: (obs_note: ObsidianObsNote) => void
|
onObsNoteSelect: (obs_note: ObsidianObsNote) => void
|
||||||
|
|
@ -79,45 +80,57 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
||||||
}
|
}
|
||||||
}, [vault])
|
}, [vault])
|
||||||
|
|
||||||
// Save vault to Automerge store
|
// Save vault to Automerge store (metadata only) and IndexedDB (content)
|
||||||
const saveVaultToAutomerge = (vault: ObsidianVault) => {
|
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) {
|
if (!automergeHandle) {
|
||||||
|
// No Automerge, just save light metadata to localStorage
|
||||||
try {
|
try {
|
||||||
const vaultRecord = importer.vaultToRecord(vault)
|
|
||||||
localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({
|
localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({
|
||||||
...vaultRecord,
|
...lightRecord,
|
||||||
lastImported: vaultRecord.lastImported instanceof Date ? vaultRecord.lastImported.toISOString() : vaultRecord.lastImported
|
lastImported: lightRecord.lastImported instanceof Date ? lightRecord.lastImported.toISOString() : lightRecord.lastImported
|
||||||
}))
|
}))
|
||||||
} catch (localStorageError) {
|
} catch (localStorageError) {
|
||||||
console.warn('Could not save vault to localStorage:', localStorageError)
|
console.warn('Could not save vault metadata to localStorage:', localStorageError)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const vaultRecord = importer.vaultToRecord(vault)
|
// Save LIGHT record (no content) to Automerge - this is safe and won't overflow
|
||||||
|
|
||||||
// Save directly to Automerge, bypassing TLDraw store validation
|
|
||||||
automergeHandle.change((doc: any) => {
|
automergeHandle.change((doc: any) => {
|
||||||
if (!doc.store) {
|
if (!doc.store) {
|
||||||
doc.store = {}
|
doc.store = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const recordToSave = {
|
const recordToSave = {
|
||||||
...vaultRecord,
|
...lightRecord,
|
||||||
lastImported: vaultRecord.lastImported instanceof Date
|
lastImported: lightRecord.lastImported instanceof Date
|
||||||
? vaultRecord.lastImported.toISOString()
|
? lightRecord.lastImported.toISOString()
|
||||||
: vaultRecord.lastImported
|
: 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 {
|
try {
|
||||||
localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({
|
localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({
|
||||||
...vaultRecord,
|
...lightRecord,
|
||||||
lastImported: vaultRecord.lastImported instanceof Date ? vaultRecord.lastImported.toISOString() : vaultRecord.lastImported
|
lastImported: lightRecord.lastImported instanceof Date ? lightRecord.lastImported.toISOString() : lightRecord.lastImported
|
||||||
}))
|
}))
|
||||||
} catch (localStorageError) {
|
} catch (localStorageError) {
|
||||||
// Silent fail for backup
|
// Silent fail for backup
|
||||||
|
|
@ -126,10 +139,9 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
||||||
console.error('Error saving vault to Automerge:', error)
|
console.error('Error saving vault to Automerge:', error)
|
||||||
// Try localStorage as fallback
|
// Try localStorage as fallback
|
||||||
try {
|
try {
|
||||||
const vaultRecord = importer.vaultToRecord(vault)
|
|
||||||
localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({
|
localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({
|
||||||
...vaultRecord,
|
...lightRecord,
|
||||||
lastImported: vaultRecord.lastImported instanceof Date ? vaultRecord.lastImported.toISOString() : vaultRecord.lastImported
|
lastImported: lightRecord.lastImported instanceof Date ? lightRecord.lastImported.toISOString() : lightRecord.lastImported
|
||||||
}))
|
}))
|
||||||
} catch (localStorageError) {
|
} catch (localStorageError) {
|
||||||
console.warn('Could not save vault to localStorage:', 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 => {
|
const loadVaultFromAutomerge = (vaultName: string): ObsidianVault | null => {
|
||||||
// Try loading from Automerge first
|
// Try loading from Automerge first
|
||||||
if (automergeHandle) {
|
if (automergeHandle) {
|
||||||
|
|
@ -145,17 +237,34 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
||||||
const doc = automergeHandle.doc()
|
const doc = automergeHandle.doc()
|
||||||
if (doc && doc.store) {
|
if (doc && doc.store) {
|
||||||
const vaultId = `obsidian_vault:${vaultName}`
|
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') {
|
if (vaultRecord && vaultRecord.typeName === 'obsidian_vault') {
|
||||||
const recordCopy = JSON.parse(JSON.stringify(vaultRecord))
|
const recordCopy = JSON.parse(JSON.stringify(vaultRecord))
|
||||||
if (typeof recordCopy.lastImported === 'string') {
|
if (typeof recordCopy.lastImported === 'string') {
|
||||||
recordCopy.lastImported = new Date(recordCopy.lastImported)
|
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)
|
return importer.recordToVault(recordCopy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error loading from Automerge:', error)
|
||||||
// Fall through to localStorage
|
// Fall through to localStorage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -164,12 +273,27 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
||||||
try {
|
try {
|
||||||
const cached = localStorage.getItem(`obsidian_vault_cache:${vaultName}`)
|
const cached = localStorage.getItem(`obsidian_vault_cache:${vaultName}`)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
const vaultRecord = JSON.parse(cached) as ObsidianVaultRecord
|
const vaultRecord = JSON.parse(cached) as (ObsidianVaultRecord | ObsidianVaultRecordLight)
|
||||||
if (vaultRecord && vaultRecord.typeName === 'obsidian_vault') {
|
if (vaultRecord && vaultRecord.typeName === 'obsidian_vault') {
|
||||||
if (typeof vaultRecord.lastImported === 'string') {
|
if (typeof vaultRecord.lastImported === 'string') {
|
||||||
vaultRecord.lastImported = new Date(vaultRecord.lastImported)
|
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) {
|
} catch (e) {
|
||||||
|
|
@ -564,8 +688,14 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleObsNoteClick = (obs_note: ObsidianObsNote) => {
|
const handleObsNoteClick = async (obs_note: ObsidianObsNote) => {
|
||||||
onObsNoteSelect(obs_note)
|
// 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) => {
|
const handleObsNoteToggle = (obs_note: ObsidianObsNote) => {
|
||||||
|
|
@ -578,9 +708,21 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
||||||
setSelectedNotes(newSelected)
|
setSelectedNotes(newSelected)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBulkImport = () => {
|
const handleBulkImport = async () => {
|
||||||
const selectedObsNotes = filteredObsNotes.filter(obs_note => selectedNotes.has(obs_note.id))
|
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())
|
setSelectedNotes(new Set())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -630,6 +772,11 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
||||||
|
|
||||||
|
|
||||||
const handleDisconnectVault = () => {
|
const handleDisconnectVault = () => {
|
||||||
|
// Clear vault from Automerge/sync when disconnecting
|
||||||
|
if (vault) {
|
||||||
|
clearVaultFromAutomerge(vault.name)
|
||||||
|
}
|
||||||
|
|
||||||
updateSession({
|
updateSession({
|
||||||
obsidianVaultPath: undefined,
|
obsidianVaultPath: undefined,
|
||||||
obsidianVaultName: undefined
|
obsidianVaultName: undefined
|
||||||
|
|
@ -646,6 +793,14 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
||||||
setIsLoadingVault(false)
|
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>) => {
|
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
// Only close if clicking on the backdrop, not on the modal content
|
// Only close if clicking on the backdrop, not on the modal content
|
||||||
if (e.target === e.currentTarget) {
|
if (e.target === e.currentTarget) {
|
||||||
|
|
@ -1119,8 +1274,16 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
||||||
>
|
>
|
||||||
🔌 Disconnect Vault
|
🔌 Disconnect Vault
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div className="selection-controls">
|
<div className="selection-controls">
|
||||||
<button
|
<button
|
||||||
onClick={handleSelectAll}
|
onClick={handleSelectAll}
|
||||||
|
|
@ -1374,8 +1537,16 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
||||||
>
|
>
|
||||||
🔌 Disconnect Vault
|
🔌 Disconnect Vault
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div className="selection-controls">
|
<div className="selection-controls">
|
||||||
<button
|
<button
|
||||||
onClick={handleSelectAll}
|
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>
|
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 {
|
export class ObsidianImporter {
|
||||||
private vault: ObsidianVault | null = null
|
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
|
* Search notes in the current vault
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -639,6 +639,14 @@ export class AutomergeDurableObject {
|
||||||
sessionId: message.sessionId || sessionId
|
sessionId: message.sessionId || sessionId
|
||||||
})
|
})
|
||||||
break
|
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:
|
default:
|
||||||
console.log("Unknown message type:", message.type)
|
console.log("Unknown message type:", message.type)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue