feat: add data safety mitigations and UI fixes for google-export branch

- Add pre-conversion backup system for format migrations
  - Backups saved to `pre-conversion-backups/{roomId}/{timestamp}_{formatType}.json`
  - Preserves original data before any destructive conversion

- Add conversion threshold guards
  - Abort if >10% records lost during conversion
  - Warn if >5% shapes lost
  - Full logging of before/after counts

- Improve unknown format handling
  - Backup unknown formats instead of silently creating empty doc
  - Log raw document keys for investigation

- Fix keyboard shortcuts dialog error
  - Handle tldraw i18n label objects ({default, menu}) instead of plain strings
  - Add getLabelString helper to safely extract string labels

- Reorder Integrations tab in Settings
  - Google Workspace now appears first, above Obsidian and Fathom

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-05 14:05:03 -08:00
parent 8892a9cf3a
commit f8092d804f
3 changed files with 284 additions and 157 deletions

View File

@ -649,8 +649,140 @@ export function UserSettingsModal({ onClose, isDarkMode, onToggleDarkMode }: Use
{activeTab === 'integrations' && ( {activeTab === 'integrations' && (
<div className="settings-section"> <div className="settings-section">
{/* Knowledge Management Section */} {/* Google Workspace - Primary Data Import */}
<h3 style={{ fontSize: '14px', fontWeight: '600', marginBottom: '12px', color: colors.text }}> <h3 style={{ fontSize: '14px', fontWeight: '600', marginBottom: '12px', color: colors.text }}>
Google Workspace
</h3>
{/* Google Workspace */}
<div
style={{
padding: '12px',
backgroundColor: colors.cardBg,
borderRadius: '8px',
border: `1px solid ${colors.cardBorder}`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<span style={{ fontSize: '20px' }}>🔐</span>
<div style={{ flex: 1 }}>
<span style={{ fontSize: '13px', fontWeight: '600', color: colors.textHeading }}>Google Workspace</span>
<p style={{ fontSize: '11px', color: colors.textMuted, marginTop: '2px' }}>
Import Gmail, Drive, Photos & Calendar - encrypted locally
</p>
</div>
<span className={`status-badge ${googleConnected ? 'success' : 'warning'}`} style={{ fontSize: '10px' }}>
{googleConnected ? 'Connected' : 'Not Connected'}
</span>
</div>
{googleConnected && totalGoogleItems > 0 && (
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
marginBottom: '12px',
padding: '8px',
backgroundColor: isDarkMode ? 'rgba(99, 102, 241, 0.1)' : 'rgba(99, 102, 241, 0.05)',
borderRadius: '6px',
}}>
{googleCounts.gmail > 0 && (
<span style={{
fontSize: '10px',
padding: '3px 8px',
borderRadius: '12px',
backgroundColor: colors.localBg,
color: colors.localText,
fontWeight: '500',
}}>
📧 {googleCounts.gmail} emails
</span>
)}
{googleCounts.drive > 0 && (
<span style={{
fontSize: '10px',
padding: '3px 8px',
borderRadius: '12px',
backgroundColor: colors.gpuBg,
color: colors.gpuText,
fontWeight: '500',
}}>
📁 {googleCounts.drive} files
</span>
)}
{googleCounts.photos > 0 && (
<span style={{
fontSize: '10px',
padding: '3px 8px',
borderRadius: '12px',
backgroundColor: colors.cloudBg,
color: colors.cloudText,
fontWeight: '500',
}}>
📷 {googleCounts.photos} photos
</span>
)}
{googleCounts.calendar > 0 && (
<span style={{
fontSize: '10px',
padding: '3px 8px',
borderRadius: '12px',
backgroundColor: colors.successBg,
color: colors.successText,
fontWeight: '500',
}}>
📅 {googleCounts.calendar} events
</span>
)}
</div>
)}
<p style={{ fontSize: '11px', color: colors.textMuted, marginBottom: '12px', lineHeight: '1.4' }}>
Your data is encrypted with AES-256 and stored only in your browser.
Choose what to share to the board.
</p>
<div style={{ display: 'flex', gap: '8px' }}>
{googleConnected ? (
<>
<button
className="settings-action-btn"
style={{ flex: 1 }}
onClick={() => setShowGoogleExportBrowser(true)}
disabled={totalGoogleItems === 0}
>
Open Data Browser
</button>
<button
className="settings-action-btn secondary"
onClick={handleGoogleDisconnect}
>
Disconnect
</button>
</>
) : (
<button
className="settings-action-btn"
style={{ width: '100%' }}
onClick={handleGoogleConnect}
disabled={googleLoading}
>
{googleLoading ? 'Connecting...' : 'Connect Google Account'}
</button>
)}
</div>
{googleConnected && totalGoogleItems === 0 && (
<p style={{ fontSize: '11px', color: colors.warningText, marginTop: '8px', textAlign: 'center' }}>
No data imported yet. Visit <a href="/google" style={{ color: colors.linkColor }}>/google</a> to import.
</p>
)}
</div>
<div className="settings-divider" />
{/* Knowledge Management Section */}
<h3 style={{ fontSize: '14px', fontWeight: '600', marginBottom: '12px', marginTop: '8px', color: colors.text }}>
Knowledge Management Knowledge Management
</h3> </h3>
@ -845,138 +977,6 @@ export function UserSettingsModal({ onClose, isDarkMode, onToggleDarkMode }: Use
)} )}
</div> </div>
<div className="settings-divider" />
{/* Data Import Section */}
<h3 style={{ fontSize: '14px', fontWeight: '600', marginBottom: '12px', marginTop: '8px', color: colors.text }}>
Data Import
</h3>
{/* Google Workspace */}
<div
style={{
padding: '12px',
backgroundColor: colors.cardBg,
borderRadius: '8px',
border: `1px solid ${colors.cardBorder}`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<span style={{ fontSize: '20px' }}>🔐</span>
<div style={{ flex: 1 }}>
<span style={{ fontSize: '13px', fontWeight: '600', color: colors.textHeading }}>Google Workspace</span>
<p style={{ fontSize: '11px', color: colors.textMuted, marginTop: '2px' }}>
Import Gmail, Drive, Photos & Calendar - encrypted locally
</p>
</div>
<span className={`status-badge ${googleConnected ? 'success' : 'warning'}`} style={{ fontSize: '10px' }}>
{googleConnected ? 'Connected' : 'Not Connected'}
</span>
</div>
{googleConnected && totalGoogleItems > 0 && (
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
marginBottom: '12px',
padding: '8px',
backgroundColor: isDarkMode ? 'rgba(99, 102, 241, 0.1)' : 'rgba(99, 102, 241, 0.05)',
borderRadius: '6px',
}}>
{googleCounts.gmail > 0 && (
<span style={{
fontSize: '10px',
padding: '3px 8px',
borderRadius: '12px',
backgroundColor: colors.localBg,
color: colors.localText,
fontWeight: '500',
}}>
📧 {googleCounts.gmail} emails
</span>
)}
{googleCounts.drive > 0 && (
<span style={{
fontSize: '10px',
padding: '3px 8px',
borderRadius: '12px',
backgroundColor: colors.gpuBg,
color: colors.gpuText,
fontWeight: '500',
}}>
📁 {googleCounts.drive} files
</span>
)}
{googleCounts.photos > 0 && (
<span style={{
fontSize: '10px',
padding: '3px 8px',
borderRadius: '12px',
backgroundColor: colors.cloudBg,
color: colors.cloudText,
fontWeight: '500',
}}>
📷 {googleCounts.photos} photos
</span>
)}
{googleCounts.calendar > 0 && (
<span style={{
fontSize: '10px',
padding: '3px 8px',
borderRadius: '12px',
backgroundColor: colors.successBg,
color: colors.successText,
fontWeight: '500',
}}>
📅 {googleCounts.calendar} events
</span>
)}
</div>
)}
<p style={{ fontSize: '11px', color: colors.textMuted, marginBottom: '12px', lineHeight: '1.4' }}>
Your data is encrypted with AES-256 and stored only in your browser.
Choose what to share to the board.
</p>
<div style={{ display: 'flex', gap: '8px' }}>
{googleConnected ? (
<>
<button
className="settings-action-btn"
style={{ flex: 1 }}
onClick={() => setShowGoogleExportBrowser(true)}
disabled={totalGoogleItems === 0}
>
Open Data Browser
</button>
<button
className="settings-action-btn secondary"
onClick={handleGoogleDisconnect}
>
Disconnect
</button>
</>
) : (
<button
className="settings-action-btn"
style={{ width: '100%' }}
onClick={handleGoogleConnect}
disabled={googleLoading}
>
{googleLoading ? 'Connecting...' : 'Connect Google Account'}
</button>
)}
</div>
{googleConnected && totalGoogleItems === 0 && (
<p style={{ fontSize: '11px', color: colors.warningText, marginTop: '8px', textAlign: 'center' }}>
No data imported yet. Visit <a href="/google" style={{ color: colors.linkColor }}>/google</a> to import.
</p>
)}
</div>
{/* Future Integrations Placeholder */} {/* Future Integrations Placeholder */}
<div style={{ marginTop: '16px', padding: '12px', backgroundColor: colors.legendBg, borderRadius: '6px', border: `1px dashed ${colors.cardBorder}` }}> <div style={{ marginTop: '16px', padding: '12px', backgroundColor: colors.legendBg, borderRadius: '6px', border: `1px dashed ${colors.cardBorder}` }}>
<p style={{ fontSize: '12px', color: colors.textMuted, textAlign: 'center' }}> <p style={{ fontSize: '12px', color: colors.textMuted, textAlign: 'center' }}>

View File

@ -406,6 +406,15 @@ function CustomSharePanel() {
const actions = useActions() const actions = useActions()
const [showShortcuts, setShowShortcuts] = React.useState(false) const [showShortcuts, setShowShortcuts] = React.useState(false)
// Helper to safely get label as string (handles i18n objects)
const getLabelString = (label: unknown, fallback: string): string => {
if (typeof label === 'string') return label
if (label && typeof label === 'object' && 'default' in label) {
return String((label as { default: string }).default)
}
return fallback
}
// Collect all tools and actions with keyboard shortcuts // Collect all tools and actions with keyboard shortcuts
const allShortcuts = React.useMemo(() => { const allShortcuts = React.useMemo(() => {
const shortcuts: { name: string; kbd: string; category: string }[] = [] const shortcuts: { name: string; kbd: string; category: string }[] = []
@ -416,7 +425,7 @@ function CustomSharePanel() {
const tool = tools[toolId] const tool = tools[toolId]
if (tool?.kbd) { if (tool?.kbd) {
shortcuts.push({ shortcuts.push({
name: tool.label || toolId, name: getLabelString(tool.label, toolId),
kbd: tool.kbd, kbd: tool.kbd,
category: 'Tools' category: 'Tools'
}) })
@ -429,7 +438,7 @@ function CustomSharePanel() {
const tool = tools[toolId] const tool = tools[toolId]
if (tool?.kbd) { if (tool?.kbd) {
shortcuts.push({ shortcuts.push({
name: tool.label || toolId, name: getLabelString(tool.label, toolId),
kbd: tool.kbd, kbd: tool.kbd,
category: 'Custom Tools' category: 'Custom Tools'
}) })
@ -442,7 +451,7 @@ function CustomSharePanel() {
const action = actions[actionId] const action = actions[actionId]
if (action?.kbd) { if (action?.kbd) {
shortcuts.push({ shortcuts.push({
name: action.label || actionId, name: getLabelString(action.label, actionId),
kbd: action.kbd, kbd: action.kbd,
category: 'Actions' category: 'Actions'
}) })
@ -455,7 +464,7 @@ function CustomSharePanel() {
const action = actions[actionId] const action = actions[actionId]
if (action?.kbd) { if (action?.kbd) {
shortcuts.push({ shortcuts.push({
name: action.label || actionId, name: getLabelString(action.label, actionId),
kbd: action.kbd, kbd: action.kbd,
category: 'Custom Actions' category: 'Custom Actions'
}) })
@ -662,10 +671,8 @@ export const components: TLComponents = {
// MycelialIntelligence moved to permanent floating bar // MycelialIntelligence moved to permanent floating bar
].filter(tool => tool && tool.kbd) ].filter(tool => tool && tool.kbd)
// Get all custom actions with keyboard shortcuts // Get only truly custom actions with keyboard shortcuts (not tldraw defaults)
const customActions = [ const customActions = [
actions["zoom-in"],
actions["zoom-out"],
actions["zoom-to-selection"], actions["zoom-to-selection"],
actions["copy-link-to-current-view"], actions["copy-link-to-current-view"],
actions["copy-focus-link"], actions["copy-focus-link"],
@ -676,16 +683,16 @@ export const components: TLComponents = {
actions["search-shapes"], actions["search-shapes"],
actions["llm"], actions["llm"],
actions["open-obsidian-browser"], actions["open-obsidian-browser"],
].filter(action => action && action.kbd) ].filter(action => action && action.kbd && typeof action.label === 'string')
return ( return (
<DefaultKeyboardShortcutsDialog {...props}> <DefaultKeyboardShortcutsDialog {...props}>
{/* Custom Tools */} {/* Custom Tools */}
{customTools.map(tool => ( {customTools.filter(tool => typeof tool.label === 'string').map(tool => (
<TldrawUiMenuItem <TldrawUiMenuItem
key={tool.id} key={tool.id}
id={tool.id} id={tool.id}
label={tool.label} label={tool.label as string}
icon={typeof tool.icon === 'string' ? tool.icon : undefined} icon={typeof tool.icon === 'string' ? tool.icon : undefined}
kbd={tool.kbd} kbd={tool.kbd}
onSelect={tool.onSelect} onSelect={tool.onSelect}
@ -697,7 +704,7 @@ export const components: TLComponents = {
<TldrawUiMenuItem <TldrawUiMenuItem
key={action.id} key={action.id}
id={action.id} id={action.id}
label={action.label} label={action.label as string}
icon={typeof action.icon === 'string' ? action.icon : undefined} icon={typeof action.icon === 'string' ? action.icon : undefined}
kbd={action.kbd} kbd={action.kbd}
onSelect={action.onSelect} onSelect={action.onSelect}

View File

@ -30,6 +30,10 @@ export class AutomergeDurableObject {
// Store the Automerge document ID for this room // Store the Automerge document ID for this room
private automergeDocumentId: string | null = null private automergeDocumentId: string | null = null
// Safety thresholds for format conversion
private static readonly CONVERSION_LOSS_THRESHOLD = 0.10 // Abort if > 10% records lost
private static readonly SHAPE_LOSS_THRESHOLD = 0.05 // Warn if > 5% shapes lost
constructor(private readonly ctx: DurableObjectState, env: Environment) { constructor(private readonly ctx: DurableObjectState, env: Environment) {
this.r2 = env.TLDRAW_BUCKET this.r2 = env.TLDRAW_BUCKET
@ -639,14 +643,28 @@ export class AutomergeDurableObject {
if (Array.isArray(rawDoc)) { if (Array.isArray(rawDoc)) {
// This is the raw Automerge document format - convert to store format // This is the raw Automerge document format - convert to store format
console.log(`Converting Automerge document format to store format for room ${this.roomId}`) console.log(`Converting Automerge document format to store format for room ${this.roomId}`)
// SAFETY: Create pre-conversion backup before destructive operation
await this.createPreConversionBackup(rawDoc, 'automerge-array')
const originalShapeCount = rawDoc.filter((r: any) => r?.state?.typeName === 'shape').length
initialDoc = this.convertAutomergeToStore(rawDoc) initialDoc = this.convertAutomergeToStore(rawDoc)
wasConverted = true wasConverted = true
// SAFETY: Validate conversion results
const convertedCount = Object.keys(initialDoc.store).length
const skippedCount = rawDoc.length - convertedCount
this.validateConversionResults(rawDoc.length, convertedCount, skippedCount, 'Automerge-array')
const convertedShapeCount = Object.values(initialDoc.store).filter((r: any) => r.typeName === 'shape').length
this.validateShapeCount(originalShapeCount, convertedShapeCount, 'Automerge-array')
const customRecords = Object.values(initialDoc.store).filter((r: any) => const customRecords = Object.values(initialDoc.store).filter((r: any) =>
r.id && typeof r.id === 'string' && r.id.startsWith('obsidian_vault:') r.id && typeof r.id === 'string' && r.id.startsWith('obsidian_vault:')
) )
console.log(`Conversion completed:`, { console.log(`Conversion completed:`, {
storeKeys: Object.keys(initialDoc.store).length, storeKeys: Object.keys(initialDoc.store).length,
shapeCount: Object.values(initialDoc.store).filter((r: any) => r.typeName === 'shape').length, shapeCount: convertedShapeCount,
customRecordCount: customRecords.length, customRecordCount: customRecords.length,
customRecordIds: customRecords.map((r: any) => r.id).slice(0, 5) customRecordIds: customRecords.map((r: any) => r.id).slice(0, 5)
}) })
@ -665,19 +683,42 @@ export class AutomergeDurableObject {
} else if ((rawDoc as any).documents && !((rawDoc as any).store)) { } else if ((rawDoc as any).documents && !((rawDoc as any).store)) {
// Migrate old format (documents array) to new format (store object) // Migrate old format (documents array) to new format (store object)
console.log(`Migrating old documents format to new store format for room ${this.roomId}`) console.log(`Migrating old documents format to new store format for room ${this.roomId}`)
// SAFETY: Create pre-conversion backup before destructive operation
await this.createPreConversionBackup(rawDoc, 'documents-array')
const originalShapeCount = ((rawDoc as any).documents || []).filter((r: any) => r?.state?.typeName === 'shape').length
initialDoc = this.migrateDocumentsToStore(rawDoc) initialDoc = this.migrateDocumentsToStore(rawDoc)
wasConverted = true wasConverted = true
// SAFETY: Validate conversion results
const documentsArray = (rawDoc as any).documents || []
const convertedCount = Object.keys(initialDoc.store).length
const skippedCount = documentsArray.length - convertedCount
this.validateConversionResults(documentsArray.length, convertedCount, skippedCount, 'Documents-array')
const convertedShapeCount = Object.values(initialDoc.store).filter((r: any) => r.typeName === 'shape').length
this.validateShapeCount(originalShapeCount, convertedShapeCount, 'Documents-array')
const customRecords = Object.values(initialDoc.store).filter((r: any) => const customRecords = Object.values(initialDoc.store).filter((r: any) =>
r.id && typeof r.id === 'string' && r.id.startsWith('obsidian_vault:') r.id && typeof r.id === 'string' && r.id.startsWith('obsidian_vault:')
) )
console.log(`Migration completed:`, { console.log(`Migration completed:`, {
storeKeys: Object.keys(initialDoc.store).length, storeKeys: Object.keys(initialDoc.store).length,
shapeCount: Object.values(initialDoc.store).filter((r: any) => r.typeName === 'shape').length, shapeCount: convertedShapeCount,
customRecordCount: customRecords.length, customRecordCount: customRecords.length,
customRecordIds: customRecords.map((r: any) => r.id).slice(0, 5) customRecordIds: customRecords.map((r: any) => r.id).slice(0, 5)
}) })
} else { } else {
console.log(`Unknown document format, creating new document`) // SAFETY: Unknown format - preserve raw data and log for investigation
console.warn(`⚠️ Unknown document format for room ${this.roomId}. Preserving raw data for manual recovery.`)
console.warn(`Raw document keys: ${Object.keys(rawDoc || {}).join(', ')}`)
// Create backup of unknown format for manual investigation
await this.createPreConversionBackup(rawDoc, 'unknown-format')
// Create empty document but log warning
console.log(`Creating new empty document due to unknown format`)
initialDoc = this.createEmptyDocument() initialDoc = this.createEmptyDocument()
} }
@ -1047,6 +1088,85 @@ export class AutomergeDurableObject {
} }
} }
/**
* Create a pre-conversion backup of the raw document before format migration.
* This ensures we can recover data if conversion goes wrong.
*/
private async createPreConversionBackup(rawDoc: any, formatType: string): Promise<boolean> {
if (!this.roomId) return false
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const backupKey = `pre-conversion-backups/${this.roomId}/${timestamp}_${formatType}.json`
console.log(`📦 Creating pre-conversion backup: ${backupKey}`)
await this.r2.put(backupKey, JSON.stringify(rawDoc), {
httpMetadata: {
contentType: 'application/json'
},
customMetadata: {
roomId: this.roomId,
formatType: formatType,
timestamp: timestamp,
reason: 'pre-conversion-safety-backup'
}
})
console.log(`✅ Pre-conversion backup created successfully: ${backupKey}`)
return true
} catch (error) {
console.error(`❌ Failed to create pre-conversion backup:`, error)
return false
}
}
/**
* Validate conversion results and throw if data loss exceeds threshold.
*/
private validateConversionResults(
originalCount: number,
convertedCount: number,
skippedCount: number,
formatType: string
): void {
if (originalCount === 0) return // Nothing to validate
const lossRate = skippedCount / originalCount
if (lossRate > AutomergeDurableObject.CONVERSION_LOSS_THRESHOLD) {
const errorMsg = `🚨 CONVERSION ABORTED: ${formatType} conversion would lose ${(lossRate * 100).toFixed(1)}% of records (${skippedCount}/${originalCount}). Threshold: ${(AutomergeDurableObject.CONVERSION_LOSS_THRESHOLD * 100)}%`
console.error(errorMsg)
throw new Error(errorMsg)
}
if (lossRate > 0) {
console.warn(`⚠️ ${formatType} conversion: ${skippedCount}/${originalCount} records (${(lossRate * 100).toFixed(1)}%) could not be converted`)
}
}
/**
* Validate shape count after conversion and warn if significant loss.
*/
private validateShapeCount(
originalShapeCount: number,
convertedShapeCount: number,
formatType: string
): void {
if (originalShapeCount === 0) return
const shapeLoss = originalShapeCount - convertedShapeCount
const shapeLossRate = shapeLoss / originalShapeCount
if (shapeLossRate > AutomergeDurableObject.SHAPE_LOSS_THRESHOLD) {
console.error(`🚨 SHAPE LOSS WARNING: ${formatType} conversion lost ${shapeLoss} shapes (${(shapeLossRate * 100).toFixed(1)}%). Original: ${originalShapeCount}, After: ${convertedShapeCount}`)
} else if (shapeLoss > 0) {
console.warn(`⚠️ ${formatType} conversion: ${shapeLoss} shapes could not be converted`)
} else {
console.log(`${formatType} conversion: All ${originalShapeCount} shapes preserved`)
}
}
private async updateDocument(newDoc: any) { private async updateDocument(newDoc: any) {
// CRITICAL: Wait for R2 load to complete before processing updates // CRITICAL: Wait for R2 load to complete before processing updates
// This ensures we have all shapes from R2 before merging client updates // This ensures we have all shapes from R2 before merging client updates